From df399272f2ad1452dfa0f713e7b7408bced93096 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:10:17 +0000 Subject: [PATCH 01/58] build(deps): bump the github-actions group across 1 directory with 5 updates Bumps the github-actions group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `6` | `7` | | [actions/setup-node](https://github.com/actions/setup-node) | `6` | `7` | | [actions/cache](https://github.com/actions/cache) | `4` | `6` | | [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `3` | `4` | | [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` | Updates `actions/checkout` from 6 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Commits](https://github.com/actions/checkout/compare/v6...v7) Updates `actions/setup-node` from 6 to 7 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) Updates `actions/cache` from 4 to 6 - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v6) Updates `docker/setup-qemu-action` from 3 to 4 - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4) Updates `docker/build-push-action` from 6 to 7 - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: docker/setup-qemu-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: docker/build-push-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/main.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d1763cba..f7ddb49a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,10 +23,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22.x' cache: 'npm' @@ -79,7 +79,7 @@ jobs: # smoke reuse the cache instead of downloading its own copy. The Storybook # step below reuses the same install. - name: Cache Playwright browsers - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('clients/web/package-lock.json') }} @@ -129,10 +129,10 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22.x' cache: 'npm' @@ -217,7 +217,7 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Log in to the Container registry uses: docker/login-action@v4 @@ -241,14 +241,14 @@ jobs: latest=true - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Build and push Docker image id: push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . push: true From bb599e5057d89e96a46b0d22faf454e821bda9bf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 11 Aug 2026 15:28:30 -0400 Subject: [PATCH 02/58] =?UTF-8?q?docs:=20add=20the=20six-month=20Inspector?= =?UTF-8?q?=20roadmap=20(Aug=202026=20=E2=86=92=20Feb=202027)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Through v1 the Inspector was a follow-along project — the spec moved, we chased it, and whatever planning capacity was left went to keeping up rather than to the tool's own design. v2 meeting the 2026-07-28 spec across all three clients lifts that constraint, but nothing written down said so, and there was no shared list of what the MCP roadmap is likely to demand of us next. Add docs/inspector-roadmap-2026-h2.md covering 2026-08-11 → 2027-02-11 (~26 weekly milestones, v2.2.0 → ~v2.27.0), in two explicitly-budgeted tracks. Track A predicts Inspector features per MCP roadmap theme and WG charter, each tagged build-now / design-now-build-on-signal / watch, so a speculative item gets a liaison rather than code: transports and session lifecycle, Server Cards (SEP-2127), Tasks retry/expiry and the extension→core migration, enterprise audit + ID-JAG, triggers/events, result types, interceptors (SEP-1763), file inputs (SEP-2356), skills (SEP-2640), primitive grouping, and conformance. Track B is the work we choose, headlined by the zoomable protocol/network timeline — lanes, spans rather than points, brush-to-filter, MRTR and task grouping — then session record/replay, a shared diff primitive, a command palette, saved-call collections feeding CI assertions, the argument-editor workstream, and Connection Doctor. The sequencing argument is that several Track B items are force multipliers for Track A: each protocol feature arrives with a rendering problem, and one general timeline plus one general diff is cheaper than a bespoke panel per SEP. So the general surfaces come first and the SEP work renders into them. Two consolidations the survey surfaced, both worth acting on regardless of whether this plan is adopted as written: - #1853, #1856, #1885, #1928, #1919 and #1910 are one defect class, not six bugs — the argument editor is not schema-aware. Treating them separately has already produced one regression from v1.x (#1928). - #962, #1936, #1951, #1944 and #1914 are one missing feature: an ordered diagnostic that reports which connection step failed and what to do. Sourcing is the published roadmap plus the nine WG and six IG charters, cited in full at the end; the doc says so, and flags that Track A should be revised if internal planning carries themes the public page omits. Status is "Draft for WG review" — merging this starts the discussion rather than settling it. Adds one file and touches nothing else. The README guide list and the AGENTS.md docs/ tree entry are deliberately left for a follow-up rather than bundled here, to keep this diff to the document itself. Markdown-only, and outside every format and typecheck glob (SOURCE_EXTENSIONS has no "md"), so no gate is affected. Closes #1980 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013CaLPZQzfWDHoPxyL1vbYL Signed-off-by: cliffhall --- docs/inspector-roadmap-2026-h2.md | 570 ++++++++++++++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 docs/inspector-roadmap-2026-h2.md diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md new file mode 100644 index 00000000..bfcee53d --- /dev/null +++ b/docs/inspector-roadmap-2026-h2.md @@ -0,0 +1,570 @@ +# Inspector Roadmap — August 2026 → February 2027 + +> A six-month plan for the Inspector client family (Web, CLI, TUI), covering both +> **spec-following work** driven by the MCP roadmap and **experience work** we choose +> for ourselves. + +**Horizon:** 2026-08-11 → 2027-02-11 (~26 weekly milestones, `v2.2.0` → ~`v2.27.0`) +**Owner:** [Inspector V2 WG](https://modelcontextprotocol.io/community/working-groups/inspector-v2) +**Status:** Draft for WG review + +--- + +## Table of Contents + +- [1. Why this document exists](#1-why-this-document-exists) +- [2. The two tracks](#2-the-two-tracks) +- [3. Track A — following the spec](#3-track-a--following-the-spec) + - [3.1 Transport evolution and scalability](#31-transport-evolution-and-scalability) + - [3.2 Server Cards](#32-server-cards) + - [3.3 Agent communication and Tasks](#33-agent-communication-and-tasks) + - [3.4 Enterprise readiness](#34-enterprise-readiness) + - [3.5 Triggers and events](#35-triggers-and-events) + - [3.6 Result type improvements](#36-result-type-improvements) + - [3.7 Interceptors](#37-interceptors) + - [3.8 File uploads](#38-file-uploads) + - [3.9 Skills over MCP](#39-skills-over-mcp) + - [3.10 Primitive grouping and tool annotations](#310-primitive-grouping-and-tool-annotations) + - [3.11 Conformance and validation](#311-conformance-and-validation) +- [4. Track B — experience work we choose](#4-track-b--experience-work-we-choose) + - [4.1 The zoomable timeline (headline)](#41-the-zoomable-timeline-headline) + - [4.2 Session record, replay, and share](#42-session-record-replay-and-share) + - [4.3 Diff and compare](#43-diff-and-compare) + - [4.4 Command palette and global search](#44-command-palette-and-global-search) + - [4.5 Saved calls and collections](#45-saved-calls-and-collections) + - [4.6 Assertions and CI flows](#46-assertions-and-ci-flows) + - [4.7 The argument editor workstream](#47-the-argument-editor-workstream) + - [4.8 Connection Doctor](#48-connection-doctor) + - [4.9 Server management and portability](#49-server-management-and-portability) + - [4.10 Workspace and layout](#410-workspace-and-layout) + - [4.11 Performance at scale](#411-performance-at-scale) + - [4.12 Accessibility and keyboard-first operation](#412-accessibility-and-keyboard-first-operation) + - [4.13 Onboarding](#413-onboarding) + - [4.14 Plugin architecture](#414-plugin-architecture) +- [5. Sequencing](#5-sequencing) +- [6. What we are deliberately not doing](#6-what-we-are-deliberately-not-doing) +- [7. Open questions](#7-open-questions) +- [8. Sources](#8-sources) + +--- + +## 1. Why this document exists + +Through v1, the Inspector was a **follow-along project**. The spec moved, we chased it, and +whatever planning capacity remained went to keeping up rather than to the tool's own design. +Every release was reactive by necessity. + +That constraint has lifted. v2 meets the 2026-07-28 spec across all three clients, on SDK v2, +with a shared `core/`, a ≥90% per-file coverage gate, and a smoke/e2e apparatus that catches +packaging failures. For the first time we can spend planned effort on **what the Inspector +should be**, not only on what the spec just became. + +This document splits the next six months into those two kinds of work, so that neither +starves the other. The explicit intent is a **roughly even split of capacity** — spec-following +work is non-negotiable but bounded, and the remaining capacity is ours to direct. + +> **Sourcing note.** The MCP roadmap circulated as a Google Doc ("MCP Roadmap Process and +> Timeline") requires authentication and could not be read directly. This plan is built from +> the **published** roadmap at `modelcontextprotocol.io/development/roadmap` (last updated +> 2026-03-05) plus the current WG and IG charters, which together cover the same themes at +> more implementation-relevant detail. If the private doc contains timelines or themes absent +> from the public page, §3 should be revised against it before the plan is adopted. + +--- + +## 2. The two tracks + +| | **Track A — Spec-following** | **Track B — Experience** | +| --------------------------- | ----------------------------------------------------- | ------------------------------------------- | +| **Driver** | MCP roadmap, WG deliverables, SEP acceptance | Our own judgment about the tool | +| **Trigger to start** | A SEP reaches Draft with a Tier-1 SDK reference impl | Whenever we have capacity | +| **Risk** | Slips when upstream slips; we cannot control the date | We control the date entirely | +| **Failure mode if starved** | Inspector stops being the reference test client | Inspector stays a protocol dump, not a tool | +| **Target capacity** | ~50% | ~50% | + +The two tracks are not independent. Several Track B items — the timeline, session +record/replay, diff — are **force multipliers for Track A**: each new protocol feature +arrives with a rendering problem, and a general timeline plus a general diff is cheaper than +one bespoke panel per SEP. That is the core scheduling argument of this plan: **build the +general surfaces early so the spec work that lands later is cheap to display.** + +### How the Inspector's role is changing + +Worth stating plainly, because it shapes the priorities below. The roadmap's Validation +section names **conformance test suites**, **SDK tiers**, and **reference implementations** as +standing investments, and SEP-2484 now requires conformance tests for final SEPs. The +Inspector is the most visible MCP client in the ecosystem and is already the thing people +reach for when a server misbehaves. + +That points at an expanded role: not just _"show me the traffic"_ but _"tell me whether this +server is correct."_ Several items below (Server Card diffing, the conformance runner, +assertions, the readiness summary) are steps toward that, and they should be evaluated as a +group rather than individually. + +--- + +## 3. Track A — following the spec + +Each subsection states the upstream theme, our read on what it means for the Inspector, and a +concrete feature list. **Confidence** flags how much of the list we can commit to now: + +- 🟢 **Build now** — the shape is known; blocked only on our own capacity. +- 🟡 **Design now, build on signal** — enough detail to design against; wait for a Draft SEP or a Tier-1 SDK impl before building. +- 🔴 **Watch** — too early to predict a UI; keep a tracking issue and a WG liaison. + +### 3.1 Transport evolution and scalability + +**Upstream:** Transports WG. Next-generation Streamable HTTP that runs statelessly across +multiple instances and behaves correctly behind load balancers and proxies; a session model +covering creation, resumption, and migration; conformance guidance for SDK authors. The +roadmap is explicit that **no additional official transports** ship this cycle. + +**Read:** This is the theme most likely to produce breaking wire changes, and the one where +the Inspector is most useful — session resumption and proxy behavior are exactly the failures +nobody can reproduce by reading code. Our era model (`legacy` / `modern` / `auto`) already +gives us the negotiation seam to add a third era behind. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Session lifecycle lane** — session id, creation, resumption, migration, and expiry as first-class events, not log lines | 🟢 | Renders into the timeline (§4.1). Buildable against today's session model; extends to the new one. | +| **`Last-Event-ID` resumption support and display** | 🟢 | Existing gap — [#920](https://github.com/modelcontextprotocol/inspector/issues/920). Do it now; it is table stakes for the new session work. | +| **Proxy / intermediary harness** — route through a configurable proxy, then deliberately misbehave: rewrite headers, drop the GET stream, close mid-response | 🟡 | Builds on [#1684](https://github.com/modelcontextprotocol/inspector/issues/1684). Needs a `misbehaving-proxy` preset in `test-servers/`. | +| **Stateless-mode verification** — issue the same request across N synthetic instances and diff the responses | 🟡 | Directly tests the property the WG is specifying. Pairs with §4.3. | +| **Third protocol era behind the existing negotiation seam** | 🟡 | Cost is low _if_ we keep era-conditional exposure rather than replacing the legacy path. | +| **Custom transport support** | 🟢 | [#1741](https://github.com/modelcontextprotocol/inspector/issues/1741). The roadmap pushes experimentation to custom transports, so the Inspector should be able to load one. | + +### 3.2 Server Cards + +**Upstream:** Server Card WG, [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) (Draft). A standard `.well-known` document exposing structured server metadata so browsers, crawlers, and registries can discover capabilities **without connecting**. Deliberately kept close to a subset of `server.json`. + +**Read:** This is the single highest-leverage Track A item for us, because it creates a new +Inspector capability rather than a new panel: **inspect before connect**. It also creates an +obvious correctness question that only a tool like ours can answer. + +| Feature | Confidence | Notes | +| ----------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Card preview** — paste a URL, fetch the card, render the capability surface, one-click add to catalog | 🟡 | The pre-connection entry point. Wait for the format to settle. | +| **Card-vs-reality diff** — compare the advertised card against what `initialize` + `*/list` actually return | 🟡 | _The_ Inspector-shaped feature here. Nobody else in the ecosystem is positioned to check this. Shares machinery with [#1034](https://github.com/modelcontextprotocol/inspector/issues/1034) and §4.3. | +| **`mcp-inspector --card-lint `** — validate a card, non-zero exit on drift | 🟡 | CI-usable; a natural companion to the conformance runner (§3.11). | +| **`server.json` support** | 🟢 | [#922](https://github.com/modelcontextprotocol/inspector/issues/922). Prerequisite — the card is a subset, so this lands first regardless. | + +### 3.3 Agent communication and Tasks + +**Upstream:** Agents WG. Tasks (`io.modelcontextprotocol/tasks`, SEP-2663) is being +**stabilized and promoted from an extension into core**. Named open gaps: **retry semantics** +(what happens on transient failure, who decides to retry) and **expiry policies** (result +retention, how clients learn a result expired). An Agents Extension is under evaluation. + +**Read:** We already drive the modern Tasks extension ourselves over a raw-wire channel, +because SDK v2 era-gates `tasks/*` out. Promotion to core will move that back under the SDK — +plan for the migration, but **keep the era-conditional exposure**; the legacy `capabilities.tasks` +path must keep working. + +| Feature | Confidence | Notes | +| -------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------- | +| **Retry visualization** — attempts, backoff, who initiated each retry | 🟡 | Design against the WG's gap list now. | +| **Expiry / TTL surfacing** — retention countdown on a completed task, distinct rendering for an expired-result error | 🟡 | Cheap once the semantics land; easy to get wrong if we guess early. | +| **Tasks as timeline spans** — a long-running task is a span, not a row | 🟢 | Falls out of §4.1 for free. The strongest argument for building the timeline first. | +| **Extension → core migration** | 🟡 | Retire the raw-wire channel when the SDK covers it; keep both paths during overlap. | +| **`Mcp-Name` header on Tasks over Streamable HTTP** | 🟢 | [#1917](https://github.com/modelcontextprotocol/inspector/issues/1917) — open bug, fix now. | +| **Discover checkmarks for task extensions** | 🟢 | [#1887](https://github.com/modelcontextprotocol/inspector/issues/1887). | + +### 3.4 Enterprise readiness + +**Upstream:** An Enterprise WG is expected to form. Four named areas: **audit trails and +observability**, **enterprise-managed auth** (Cross-App Access / ID-JAG), **gateway and proxy +patterns**, and **configuration portability**. Most output is expected as extensions rather +than core spec changes. Related: the Enterprise-Managed Authorization IG, and sponsored work +on [SEP-1932 (DPoP)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932) and [SEP-1933 (Workload Identity Federation)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933). + +**Read:** "Audit trails and observability, in a form enterprises can feed into their existing +pipelines" is a description of something the Inspector nearly already has. We hold the entire +session; we simply cannot **export** it in any pipeline-shaped format. That gap is cheap to +close and disproportionately valuable. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **OTLP export** — emit the session as OpenTelemetry spans; show trace/span ids inline; "copy as trace" | 🟢 | SEP-414 already puts trace context in `_meta`. Buildable today, no upstream dependency. | +| **Structured audit transcript** — the full session as a stable, documented JSON artifact | 🟢 | Shares its format with §4.2 record/replay. Build once, use for both. | +| **Machine-readable readiness summary** | 🟢 | [#1916](https://github.com/modelcontextprotocol/inspector/issues/1916). | +| **ID-JAG / Cross-App Access test flow** | 🟡 | The EMA IG exists specifically because this only works when IdP + client + AS interoperate. A test client is exactly what they lack. Related: [#1937](https://github.com/modelcontextprotocol/inspector/issues/1937), [#571](https://github.com/modelcontextprotocol/inspector/issues/571). | +| **DPoP and Workload Identity Federation** | 🔴 | Both sponsored but pre-acceptance. Watch; do not build. | +| **Gateway mode** — declare an intermediary, then show what we sent vs. what the gateway forwarded | 🟡 | Depends on the Gateways IG settling propagation semantics. | +| **Configuration portability** | 🟢 | [#1912](https://github.com/modelcontextprotocol/inspector/issues/1912), [#904](https://github.com/modelcontextprotocol/inspector/issues/904), plus `server.json` (§3.2). | + +### 3.5 Triggers and events + +**Upstream:** Triggers and Events WG. A standardized server→client callback mechanism +(webhooks or similar), with subscription lifecycle and **ordering guarantees that hold across +all transports**. Status: "SEP: Events in MCP v1 RFC" — **Ideating**. + +**Read:** ⚠️ **This is the largest architectural change on the horizon for us, and the one we +are least prepared for.** Every Inspector surface today assumes we are the party that +_initiated_ the connection. A webhook mechanism makes us a **server** — we must host a +publicly reachable callback endpoint, which for a tool that usually runs on `localhost` is a +real problem (tunnels, port forwarding, or a relay). + +We should start the design conversation **now**, well ahead of the SEP, and bring it to the +WG as implementation feedback. The ordering-guarantee requirement in particular is +untestable without a client that records arrival order — which is us. + +| Feature | Confidence | Notes | +| ----------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Callback receiver** — backend-hosted endpoint, its URL registered as the trigger target | 🔴 | Needs design now, build later. Security review mandatory: an inbound public endpoint on a process that spawns subprocesses is a serious surface. | +| **Local reachability story** — tunnel integration or documented guidance | 🔴 | Likely the hardest UX problem of the whole six months. | +| **Delivery log with ordering and duplicate assertions** | 🔴 | The conformance value: did events arrive in the promised order? were any redelivered? | + +### 3.6 Result type improvements + +**Upstream:** "On the Horizon." **Streamed results** (incremental output for generated text, +audio, video frames) and **reference-based results** (client decides when to pull a large +payload into context). Explicitly cross-cutting — streaming touches transport, references +touch the schema. + +**Read:** Streaming changes how every result panel renders: today we display a _result_, and +we would need to display a _stream that becomes a result_. Worth a rendering abstraction +before the SEP, not after. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------- | +| **Incremental result rendering** — progressive display, with time-to-first-chunk and inter-chunk timing | 🔴 | The timing view is Inspector-shaped; the timeline is the natural home. | +| **Reference-result handling** — show a handle plus an explicit "pull payload", with size accounting | 🔴 | Also a good default for large payloads _today_, independent of the SEP (see §4.11). | + +### 3.7 Interceptors + +**Upstream:** Interceptors WG, [SEP-1763](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2076) (Draft). Interceptors as a new primitive with two types — **validators** (pass/fail) and **mutators** (transform payloads) — across in-process, sidecar, and remote deployment models, with priority-based chain ordering and audit-mode semantics. A **CLI client for interceptor invocation and testing** is a listed WG deliverable (Ideating, unowned). + +**Read:** Two things stand out. First, "CLI client for interceptor invocation and testing" is +**an unclaimed deliverable that describes our CLI**. Worth raising with the WG — Ola co-leads +both groups, so the liaison already exists. Second, an interceptor chain is a +_before → after payload transformation_, which is a diff, which we should already be able to +render (§4.3). + +| Feature | Confidence | Notes | +| ---------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------- | +| **Interceptor test bench** — register a chain, show before/after diff per hop, visualize priority ordering | 🟡 | The clearest "Inspector as the reference tool" opportunity of the six months. | +| **Audit-mode rendering** — what _would_ have been blocked or mutated | 🟡 | Follows the SEP's audit semantics. | +| **CLI interceptor invocation** | 🟡 | **Action: raise with the Interceptors WG.** If we take it, it needs its own milestone allocation. | +| **Our plugin architecture as an interceptor host** | 🟡 | [#1025](https://github.com/modelcontextprotocol/inspector/issues/1025). Prevents us building two extension mechanisms. | + +### 3.8 File uploads + +**Upstream:** File Uploads WG, [SEP-2356](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2356) (Draft, TS SDK reference impl targeted End May). Declarative `FileInputDescriptor` on tool input schemas and elicitation schemas, so hosts render native file pickers. Success criteria explicitly include **"at least one production host rendering a native file picker from the descriptor."** + +**Read:** The most tractable Track A item on the list — narrow, well-specified, with a TS SDK +reference implementation coming, and we are a credible candidate for that "production host." +It touches three surfaces: `SchemaForm` (Tools), elicitation forms, and MCP Apps. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------ | +| **File picker in `SchemaForm`** when a descriptor is present, with data-URI encoding | 🟡 | Wait for the TS SDK types, then build. Low risk. | +| **Same in elicitation forms** | 🟡 | Shared component. | +| **Size guardrails and host-side validation** | 🟡 | The SEP references OWASP ASVS V5. | + +### 3.9 Skills over MCP + +**Upstream:** Skills Over MCP WG, [SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640) (In Review, Extensions Track). Resources-based; a reference implementation is also In Review. + +**Read:** Because it is Resources-based, the incremental cost is low — a Skills view over the +existing resource machinery rather than a new subsystem. + +| Feature | Confidence | Notes | +| -------------------------------------------------------- | ---------- | -------------------------------------------------------------------- | +| **Skills view** — list, preview content, show activation | 🟡 | Gate on the negotiated extension, the way the Tasks tab gates today. | + +### 3.10 Primitive grouping and tool annotations + +**Upstream:** Two IGs. **Primitive Grouping** explores organizing Tools/Resources/Prompts +beyond flat lists — deliberately not picking one canonical pattern early. **Tool Annotations** +is consolidating six independent annotation SEPs and considering runtime annotations and tool +_response_ annotations. + +**Read:** Grouping is the rare case where the spec-following work and the UX work are the same +work. Flat lists are already our weakest surface on large servers — [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) (duplicate tool names) was a symptom. **Build the grouped sidebar as a UX +improvement now**, and adopt whatever grouping the IG lands as a data source later. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------- | +| **Grouped / tree sidebars with group-aware search** | 🟢 | Build now on client-side heuristics (name prefixes, annotations). Ship value immediately; swap the data source later. | +| **Richer annotation rendering** | 🟢 | Extends the existing `AnnotationBadge`. | +| **Annotation-driven confirmation** before a `destructiveHint` call | 🟢 | Small, obviously correct, no upstream dependency. | +| **Runtime / response annotations** | 🔴 | Watch. | + +### 3.11 Conformance and validation + +**Upstream:** Standing investment — conformance test suites, SDK tiers ([SEP-1730](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1730)), reference implementations. [SEP-2484](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2484) now **requires conformance tests for final SEPs**, and the EMA IG is explicitly contributing scenarios to the `modelcontextprotocol/conformance` repository. + +**Read:** A conformance suite needs a driver and a report. We are the natural driver, and we +already have a CLI that exits non-zero. This is the clearest path to the expanded role +described in §2 — and unlike most of Track A, **it is not gated on any SEP**. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Conformance runner** — run the suite against a connected server, render pass/fail per assertion | 🟡 | Needs coordination on the suite's programmatic interface. **Action: open a conversation with the conformance maintainers.** | +| **`mcp-inspector --conformance` for CI** | 🟡 | Same engine, CLI report, exit code. | +| **Strict schema validation with actionable errors** | 🟢 | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015). No dependency; start here. | + +--- + +## 4. Track B — experience work we choose + +Nothing in this section waits on a SEP. Ordered by leverage, not by effort. + +### 4.1 The zoomable timeline (headline) + +**Committed.** The single feature that most changes what the Inspector _is_. + +The Protocol and Network screens are chronological lists. A list answers "what happened next" +but not "what happened _at the same time_", "how long did this take", or "which of these +caused that" — and those are the questions people actually bring to the Inspector. A +session with an MRTR round-trip, a long-running task, a subscription stream, and a +mid-session OAuth step-up is, in list form, an interleaved mess. On a time axis it is legible +at a glance. + +**Design sketch:** + +- **A third view over the existing stores**, not a new data path. Protocol, Network, and + Timeline become three renderings of one session. This keeps the coverage gate and the + existing `protocolUtils` derivations intact. +- **Lanes**, each independently collapsible: + `client → server` · `server → client` · notifications · tasks · subscription streams · OAuth/auth · errors +- **Spans, not points.** A request occupies from send to response; a task occupies its whole + lifetime; a stream is a bar with events on it. Duration becomes visible, which is most of + the value. +- **Zoom and pan** across the full range, from whole-session down to sub-millisecond. + Brush-to-select a range and filter every other view to it. +- **Grouping** — an MRTR conversation is one collapsible span containing its rounds; a task + contains its polls. +- **Click through** to the existing Protocol/Network entry. The timeline is navigation, not a + replacement. +- **A pinned mini-timeline strip** above every tab, so a spike is visible while you are in + Tools, and clicking it jumps to the full view. +- **Latency distribution** as a secondary view — per method, so a slow tool is obvious. +- **Virtualized**, keyboard-navigable, and rendered from the same store the other views use. + +**Deliberately out of scope for v1 of this feature:** cross-server correlation (needs §4.10), +and OTLP-shaped nesting (needs §3.4). + +### 4.2 Session record, replay, and share + +Save a complete session — protocol log, network log, server config, negotiated capabilities — +to a single file. Reopen it later, on another machine, with no server running. Attach it to a +bug report. + +This changes issue triage from "works on my machine" into an artifact, and it is the same +serialization format as the enterprise audit transcript (§3.4) — **build the format once**. +Replay also gives us fixtures: a recorded session is a regression test. + +### 4.3 Diff and compare + +Two sessions, or two servers, side by side. Concretely: + +- **Capability diff** — reconnect after changing your server, see exactly what moved in + `tools/list` / `resources/list` / `prompts/list`. ([#1034](https://github.com/modelcontextprotocol/inspector/issues/1034)) +- **Session diff** — same calls, two servers, what differed. +- **Payload diff** — before/after for any pair of JSON documents. + +The payload differ is a **shared primitive**: interceptor before/after (§3.7), Server +Card-vs-reality (§3.2), and stateless-instance comparison (§3.1) are all the same widget with +different inputs. Build it as a component first, then wire the three consumers. + +### 4.4 Command palette and global search + +`⌘K` to jump to any server, tool, resource, or prompt; re-run the last call; switch tabs. Plus +full-text search across the protocol log with a real filter syntax (`method:tools/call +status:error duration:>500ms`). The Inspector is currently a mouse-driven app; for a developer +tool that is a daily tax. + +### 4.5 Saved calls and collections + +Name a tool call with its arguments, save it, re-run it, parameterize it, share it. A +Postman-collection model for MCP. The single most requested shape of workflow improvement for +any protocol client, and it composes directly with §4.6. + +### 4.6 Assertions and CI flows + +Attach expectations to a saved call — result matches schema, field equals value, latency under +a bound — and run the collection from the CLI with a non-zero exit on failure. This turns the +Inspector from an interactive tool into part of a server author's test suite, and it shares an +engine with the conformance runner (§3.11). +Related: [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1886](https://github.com/modelcontextprotocol/inspector/issues/1886), [#1916](https://github.com/modelcontextprotocol/inspector/issues/1916). + +### 4.7 The argument editor workstream + +Six open issues are all the same defect class — the argument editor is not schema-aware: + +| Issue | Symptom | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------- | +| [#1853](https://github.com/modelcontextprotocol/inspector/issues/1853) | JSON parameter editor escaping while typing | +| [#1856](https://github.com/modelcontextprotocol/inspector/issues/1856) | Backspace recursively escapes JSON tool inputs | +| [#1885](https://github.com/modelcontextprotocol/inspector/issues/1885) | Null values corrupted with cascading escapes | +| [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) | Nullable enums fall back to a broken raw Textarea (v1.x regression) | +| [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) | Resource templates lack RFC 6570 expansion | +| [#1910](https://github.com/modelcontextprotocol/inspector/issues/1910) | Complex `_meta` not expressible | + +**Fix them as one workstream, not six bugs.** A proper schema-aware editor (CodeMirror or +Monaco with JSON Schema integration) resolves the class and unblocks file inputs (§3.8) and +strict validation (§3.11). Treating them individually has already produced one regression from +v1. + +### 4.8 Connection Doctor + +Connection failures are currently opaque, and five open issues say so +([#962](https://github.com/modelcontextprotocol/inspector/issues/962), [#1936](https://github.com/modelcontextprotocol/inspector/issues/1936), [#1951](https://github.com/modelcontextprotocol/inspector/issues/1951), [#1944](https://github.com/modelcontextprotocol/inspector/issues/1944), [#1914](https://github.com/modelcontextprotocol/inspector/issues/1914)). + +Run an ordered checklist on failure — DNS · TCP · TLS (including local-cert cases) · +`/.well-known` discovery · protocol version negotiation · auth — and report **which step +failed and what to do about it**. First-connection success is the entire first impression of +the tool, and today a `https://localhost` server or a dev container silently fails. + +Bundle the related fixes: `*.localhost` domains ([#1944](https://github.com/modelcontextprotocol/inspector/issues/1944)), the trusted-local-host OAuth HTTP +exception ([#1911](https://github.com/modelcontextprotocol/inspector/issues/1911)), and the ghost-server entry left by a failed manual connect ([#1914](https://github.com/modelcontextprotocol/inspector/issues/1914)). + +### 4.9 Server management and portability + +Already well represented on the board; grouping it here so it is scheduled as a theme rather +than piecemeal: rich server configuration ([#1857](https://github.com/modelcontextprotocol/inspector/issues/1857)), custom headers and cookies ([#1915](https://github.com/modelcontextprotocol/inspector/issues/1915)), +auth/token URL overrides ([#1906](https://github.com/modelcontextprotocol/inspector/issues/1906)), file-backed secrets where no OS keychain exists ([#1950](https://github.com/modelcontextprotocol/inspector/issues/1950)), +paste-MCP-JSON ([#904](https://github.com/modelcontextprotocol/inspector/issues/904)), and registry discovery ([#1101](https://github.com/modelcontextprotocol/inspector/issues/1101)). + +### 4.10 Workspace and layout + +Multiple servers side by side — the actual shape of debugging a gateway, or comparing a +server against a reference implementation. Detachable/resizable panels, remembered layout per +server, density modes, and full-collapse ([#928](https://github.com/modelcontextprotocol/inspector/issues/928)). Prerequisite for cross-server timeline +correlation. + +### 4.11 Performance at scale + +A 1000-tool server or a long-running session should not degrade. Virtualize the long lists and +logs; cap in-memory protocol history with spill-to-disk; truncate large payloads by default +with explicit expansion (which is also the right default for reference results, §3.6). + +### 4.12 Accessibility and keyboard-first operation + +Full keyboard operation across every tab, correct roles and labels, high-contrast support, +and `prefers-reduced-motion` (which the timeline's animations will make newly relevant). We +have a Storybook a11y harness already; the gap is coverage, not tooling. + +### 4.13 Onboarding + +A first run currently presents an empty server list and no path forward. Add a guided first +connection, one-click example servers drawn from `test-servers/`, and inline links from each +panel to the relevant spec section. + +### 4.14 Plugin architecture + +[#1025](https://github.com/modelcontextprotocol/inspector/issues/1025). The multiplier on everything above — custom panels, custom transports (§3.1), +interceptor hosting (§3.7), and community-contributed views without core changes. Sequenced +late deliberately: designing a plugin API before the timeline, diff, and session format exist +would mean designing it against the wrong surfaces. + +--- + +## 5. Sequencing + +Four phases of roughly six weekly milestones each. Track A items appear where their upstream +signal is expected; Track B items are placed to unblock Track A wherever possible. + +### Phase 1 — Foundations (~`v2.2` – `v2.7`, Aug–Sep 2026) + +_Build the general surfaces the rest of the plan renders into, and clear the debt that makes +first impressions bad._ + +- 🅑 **Zoomable timeline v1** — lanes, spans, zoom/pan, click-through +- 🅑 **Argument editor workstream** (§4.7) — closes six issues as one +- 🅑 **Connection Doctor** (§4.8) + the local-host connection fixes +- 🅐 `Last-Event-ID` resumption ([#920](https://github.com/modelcontextprotocol/inspector/issues/920)); `Mcp-Name` on Tasks ([#1917](https://github.com/modelcontextprotocol/inspector/issues/1917)); discover checkmarks ([#1887](https://github.com/modelcontextprotocol/inspector/issues/1887)) +- 🅐 `server.json` support ([#922](https://github.com/modelcontextprotocol/inspector/issues/922)) — prerequisite for Server Cards +- ⚙️ Windows CI/gate fixes already in `v2.2.0` + +### Phase 2 — Artifacts and comparison (~`v2.8` – `v2.13`, Sep–Nov 2026) + +_Make sessions into things you can keep, share, and compare._ + +- 🅑 **Session record / replay / share** (§4.2) — format shared with audit transcript +- 🅑 **Diff primitive** (§4.3) — then wire capability diff ([#1034](https://github.com/modelcontextprotocol/inspector/issues/1034)) +- 🅑 **Command palette and global search** (§4.4) +- 🅐 **OTLP export and audit transcript** (§3.4) — no upstream dependency +- 🅐 **Grouped sidebars** (§3.10) on client-side heuristics +- 🅐 Strict schema validation ([#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015)) +- 🅐 Timeline lanes for tasks and sessions (falls out of Phase 1) + +### Phase 3 — Automation and spec catch-up (~`v2.14` – `v2.20`, Nov 2026 – Jan 2027) + +_Turn the Inspector into something you can run in CI, and absorb the SEPs that have landed._ + +- 🅑 **Saved calls / collections** (§4.5) → **assertions and CI flows** (§4.6) +- 🅐 **Conformance runner** (§3.11) — shares the assertion engine +- 🅐 **File uploads** (§3.8) — assumes the TS SDK reference impl has shipped +- 🅐 **Server Card preview + card-vs-reality diff** (§3.2) — assumes SEP-2127 has settled +- 🅐 **Skills view** (§3.9) — assumes SEP-2640 accepted +- 🅑 Performance at scale (§4.11); accessibility pass (§4.12) + +### Phase 4 — Frontier (~`v2.21` – `v2.27`, Jan–Feb 2027) + +_The items whose shape we cannot yet commit to, plus the multiplier._ + +- 🅐 **Interceptor test bench** (§3.7) — and a decision on owning the WG's CLI deliverable +- 🅐 **Triggers/events receiver** (§3.5) — design throughout, build only if the SEP lands +- 🅐 **Transport/session work** (§3.1) — proxy harness, stateless verification, third era +- 🅐 **ID-JAG / Cross-App Access flow** (§3.4) +- 🅑 **Plugin architecture** (§4.14) — designed against surfaces that now exist +- 🅑 Workspace and layout (§4.10); onboarding (§4.13) + +### Standing commitments across all phases + +- **Weekly milestone cadence** and the `npm run ci` gate are unchanged. +- **Bug and triage capacity is reserved, not scheduled.** The board's Incoming queue keeps + flowing regardless of phase. +- **WG liaison**: attend Transports, Agents, Triggers, Interceptors, and Server Card sessions + and feed implementation experience back. Several items above are as much _inputs to_ the + spec as outputs of it. + +--- + +## 6. What we are deliberately not doing + +Stating these so they are decisions rather than oversights. + +- **Not building bespoke panels per SEP.** Where a new feature can render into the timeline, + the diff, or the session format, it does. A new top-level tab needs justification. +- **Not chasing pre-Draft SEPs.** 🔴 items get a tracking issue and a WG liaison, not code. + We were burned by this in v1. +- **Not publishing `core/` as a package this cycle.** [#1636](https://github.com/modelcontextprotocol/inspector/issues/1636) stays deferred; it adds an API + compatibility obligation we cannot yet afford. +- **Not adding transports beyond what the spec blesses**, per the roadmap — but §3.1 makes + _custom_ transports loadable so the community can experiment. +- **Not building a second extension mechanism.** If we host interceptors, they run on the + plugin architecture (§4.14). + +--- + +## 7. Open questions + +For WG discussion before this plan is adopted. + +1. **Does the private roadmap doc change §3?** This plan is built from the public roadmap; the + private doc may carry timelines or themes it omits. +2. **Do we claim the Interceptors WG's "CLI client for interceptor invocation and testing"?** + It is Ideating and unowned, it describes our CLI, and we have a co-lead in common. If yes, + it needs milestone allocation in Phase 3, not Phase 4. +3. **How far do we take the conformance role?** §3.11 and §4.6 point at "the Inspector tells + you whether your server is correct." That is a real expansion of mission — worth an + explicit yes or no, and possibly a charter amendment. +4. **Who owns the triggers/events reachability problem?** A publicly reachable callback + endpoint on a localhost dev tool is a security question as much as a UX one, and it needs + an owner before Phase 4. +5. **Is the ~50/50 capacity split right?** It is an assertion in this draft, not a measurement. +6. **Timeline v1 scope.** The §4.1 sketch is deliberately broad. Which parts are v1 and which + are follow-ups should be settled before Phase 1 starts. + +--- + +## 8. Sources + +- [MCP Roadmap](https://modelcontextprotocol.io/development/roadmap) (last updated 2026-03-05) +- WG charters: [Inspector V2](https://modelcontextprotocol.io/community/working-groups/inspector-v2) · [Server Card](https://modelcontextprotocol.io/community/working-groups/server-card) · [Triggers & Events](https://modelcontextprotocol.io/community/working-groups/triggers-events) · [Agents](https://modelcontextprotocol.io/community/working-groups/agents) · [Interceptors](https://modelcontextprotocol.io/community/working-groups/interceptors) · [File Uploads](https://modelcontextprotocol.io/community/working-groups/file-uploads) · [Skills Over MCP](https://modelcontextprotocol.io/community/working-groups/skills-over-mcp) +- IG charters: [Primitive Grouping](https://modelcontextprotocol.io/community/interest-groups/primitive-grouping) · [Tool Annotations](https://modelcontextprotocol.io/community/interest-groups/tool-annotations) · [Enterprise-Managed Authorization](https://modelcontextprotocol.io/community/interest-groups/enterprise-managed-authorization) +- Internal: [`specification/v2_new_spec_impact.md`](../specification/v2_new_spec_impact.md) · [`specification/v2_scope.md`](../specification/v2_scope.md) · [`specification/v2_ux_features.md`](../specification/v2_ux_features.md) +- [Inspector V2 project board (#28)](https://github.com/orgs/modelcontextprotocol/projects/28) From cf834633ba99a569ae766083bde166646fc13ac6 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 12 Aug 2026 08:56:24 -0700 Subject: [PATCH 03/58] fix: add eol=lf policy to .gitattributes so Windows checkouts stay clean Co-Authored-By: Claude Fable 5 --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index cf968721..056e3cb9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ +* text=auto eol=lf package-lock.json linguist-generated=true From 9f7895dcd976daca6bbe41efc1b381ec4e82b0c1 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 12 Aug 2026 08:57:35 -0700 Subject: [PATCH 04/58] fix: invoke the registered SIGINT listener directly in run-method.test instead of emitting a process-wide signal Co-Authored-By: Claude Fable 5 --- clients/cli/__tests__/run-method.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/clients/cli/__tests__/run-method.test.ts b/clients/cli/__tests__/run-method.test.ts index 783ba60b..d8599223 100644 --- a/clients/cli/__tests__/run-method.test.ts +++ b/clients/cli/__tests__/run-method.test.ts @@ -258,18 +258,29 @@ describe("runMethod", () => { ); expect(stdout.trim().split("\n")).toHaveLength(2); + // Invoke only the SIGINT handler consumeMethodOutcome registers, rather + // than broadcasting `process.emit("SIGINT")` process-wide: `atomically` + // (loaded via core's store-io) pulls in `when-exit`, whose module-level + // `process.once("SIGINT")` handler re-raises the signal with + // `process.kill(process.pid, "SIGINT")` — on Windows an unconditional + // TerminateProcess that kills the vitest worker fork mid-run (#1941). + const listenersBefore = new Set(process.listeners("SIGINT")); const streamPromise = consumeMethodOutcome( { kind: "stream", label: "t", start: (write) => { write({ hi: true }); - queueMicrotask(() => process.emit("SIGINT")); return () => {}; }, }, {}, ); + const added = process + .listeners("SIGINT") + .filter((listener) => !listenersBefore.has(listener)); + expect(added).toHaveLength(1); + for (const listener of added) listener("SIGINT"); await streamPromise; expect(stdout).toContain('"hi":true'); } finally { From 9c6e4aeb4e827937abbef489ac5cd154a8919d6a Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 12 Aug 2026 08:57:44 -0700 Subject: [PATCH 05/58] fix: settle ink input-handler re-subscription after frame waits in TUI App tests Co-Authored-By: Claude Fable 5 --- clients/tui/__tests__/App.test.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index d70b9c27..9025b52e 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -552,9 +552,29 @@ async function press(r: RenderResult, keys: string[]) { */ const POLL_TRIES = 100; +/** + * One check-phase turn, queued BEHIND React's already-scheduled passive-effect + * flush. A frame observed by a poll predicate is written during React's + * COMMIT, but ink re-arms its useInput listeners in the passive-effect flush + * React schedules (via setImmediate in Node) during that same commit. Node's + * event loop runs the timers phase before the check phase, so a 25ms poll + * tick can observe the new frame and let the test write the next keypress + * BEFORE that flush has run — the key is then dispatched to the previous + * commit's stale useInput closures (where e.g. pendingStepUp is still null) + * and silently swallowed (#1942). Yielding one setImmediate turn after the + * predicate passes sequences the next stdin write after the flush (FIFO + * within the check queue), so "frame visible" once again implies "input + * handlers armed". + */ +const settleInputHandlers = () => + new Promise((resolve) => setImmediate(resolve)); + async function waitUntil(predicate: () => boolean, tries = POLL_TRIES) { for (let i = 0; i < tries; i++) { - if (predicate()) return; + if (predicate()) { + await settleInputHandlers(); + return; + } await tick(); } } From adde9ccf948a3d23fff93270be6c53188f4d5d0b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 12:13:06 -0400 Subject: [PATCH 06/58] fix: bind loopback by address and pin the sandbox port so containers work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defaults that each break the Inspector inside a container. #1951 — the web UI never loads in a VS Code dev container. `HOST` defaulted to the NAME `localhost`, and `server.listen(port, host)` resolves a name through `dns.lookup` and binds the SINGLE address it gets back. Since Node 17 the default result order is `verbatim`, so on a glibc Linux host — whose /etc/hosts maps localhost to both 127.0.0.1 and ::1 — that is `::1`. The server bound IPv6 loopback only and refused every IPv4 client, including the dev container port forwarder. It presents as an endless spinner rather than a connection error because the forwarder accepts on the host side before failing to connect inward. Default to the ADDRESS 127.0.0.1 instead; an explicit HOST=localhost is still honored as typed. #2008 — the Apps tab is unreachable in any container. The MCP Apps sandbox listened on an OS-assigned port, so it changed every run and could never be named in a devcontainer `forwardPorts`, a `docker run -p`, or a tunnel. Default it to a fixed 6275, mirroring CLIENT_PORT's fixed 6274, and retry once on an OS-assigned port (loudly) if it is taken — the collision a dynamic default never had. Verified in the reporter's exact image (mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie): both listeners come up on 127.0.0.1 and answer 200 on 6274 and 6275/sandbox. Also EXPOSE 6275 and set MCP_SANDBOX_PORT in the image, and document both defaults in the web README, the migration guide, and the root README's Docker section. Signed-off-by: cliffhall --- AGENTS.md | 5 +- Dockerfile | 6 +- README.md | 9 ++ clients/web/README.md | 6 +- clients/web/server/resolve-bind-host.ts | 43 ++++-- clients/web/server/sandbox-controller.ts | 61 ++++++++- .../server/resolve-bind-host.test.ts | 16 ++- .../server/sandbox-controller.test.ts | 127 +++++++++++++++--- .../server/web-server-config.test.ts | 21 +-- docs/v1-to-v2-migration.md | 8 +- 10 files changed, 248 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 21061009..4123ccad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,10 @@ v2/main/ │ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server), │ │ │ # inject-auth-token.ts (embeds the API token into served index.html), │ │ │ # vite-base-config.ts (shared optimizeDeps exclusions), -│ │ │ # resolve-bind-host.ts (bind-host POLICY: refuses an +│ │ │ # resolve-bind-host.ts (bind-host POLICY: defaults to +│ │ │ # 127.0.0.1 — an ADDRESS, never the name `localhost`, which +│ │ │ # listen() resolves to a SINGLE family (::1 on glibc Linux), +│ │ │ # refusing every IPv4 client — #1951; and refuses an │ │ │ # all-interfaces HOST unless DANGEROUSLY_BIND_ALL_INTERFACES; │ │ │ # the all-interfaces DETECTION is core/node/hostUrl.isAllInterfacesHost. │ │ │ # Used by both bind points — web-server-config.ts + vite.config.ts — #1795), diff --git a/Dockerfile b/Dockerfile index f87a32ce..24455398 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,8 +31,12 @@ RUN npm install -g /tmp/inspector.tgz && rm /tmp/inspector.tgz ENV HOST=0.0.0.0 \ DANGEROUSLY_BIND_ALL_INTERFACES=true \ CLIENT_PORT=6274 \ + MCP_SANDBOX_PORT=6275 \ MCP_AUTO_OPEN_ENABLED=false -EXPOSE 6274 +# 6275 is the MCP Apps sandbox, a second listener the browser reaches directly. +# It is only needed for the Apps tab, so it is EXPOSEd but publishing it is +# optional — see the Docker section of the root README. +EXPOSE 6274 6275 # Run as the non-root `node` user the base image ships. The inspector resolves # its runtime-state dir (default catalog, OAuth token storage) from `HOME` diff --git a/README.md b/README.md index 55459ad0..35b1ce6d 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,15 @@ docker build -t mcp-inspector . docker run --rm -p 127.0.0.1:6274:6274 mcp-inspector ``` +**Using the Apps tab? Publish `6275` too.** The MCP Apps sandbox is a second listener the browser reaches directly, on `MCP_SANDBOX_PORT` (default `6275`). Nothing else needs it, so the single-port commands above are fine for ordinary inspection — but the Apps tab renders a blank widget without it: + +```bash +docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \ + ghcr.io/modelcontextprotocol/inspector +``` + +Publish it on the **same port number** inside and out. The sandbox URL is handed to the browser via `/api/config` as `http://localhost:/sandbox`, so remapping it (`-p 9000:6275`) advertises a port the browser can't reach; use `-e MCP_SANDBOX_PORT=9000 -p 127.0.0.1:9000:9000` instead. + **Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publishing wider needs a real access-control boundary in front of the Inspector — a reverse proxy that authenticates, an SSH tunnel, a private network. Setting your own `MCP_INSPECTOR_API_TOKEN` does **not** substitute: `GET /` discloses whatever token is in use, so a custom one is harvested exactly as easily as a generated one. **Keeping the servers you add.** The Inspector saves your server list to `$HOME/.mcp-inspector/mcp.json`, which in the image is `/home/node/.mcp-inspector/mcp.json` — inside the container's writable layer, so `--rm` discards it and every run starts with an empty list. Mount a volume there to keep it: diff --git a/clients/web/README.md b/clients/web/README.md index a3facd92..01873854 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -162,7 +162,9 @@ The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer ## Host binding & the origin allow-list -Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error. +Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds **`127.0.0.1`** by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error. + +**The default is the IPv4 loopback _address_, deliberately not the name `localhost`** (#1951). `server.listen(port, host)` resolves a name through `dns.lookup` and binds the **single** address it gets back; since Node 17 the default result order is `verbatim`, so on a glibc Linux host — whose `/etc/hosts` maps `localhost` to both `127.0.0.1` and `::1` — that first address is `::1`. `HOST=localhost` there binds IPv6 loopback **only** and refuses every IPv4 client. On a desktop this is invisible (the browser resolves `localhost` the same way and lands on `::1` too); it breaks wherever the connection is made by something that pins `127.0.0.1` — a VS Code dev container's port forwarder, `ssh -L 6274:127.0.0.1:6274`, a container healthcheck. Binding the address takes the resolver out of the decision. Browsing to `http://localhost:PORT` still works, since browsers fall back across address families and the loopback allow-list covers all three forms. An explicit `HOST=localhost` is still honored as typed — the single-family bind is then your choice. The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. **Each entry must include the scheme** — `http://localhost:6274`, not `localhost:6274` (a scheme-less value is dropped with a warning). `ALLOWED_ORIGINS` **replaces** the default list (it does not merge), so **list every origin you'll browse from, including the loopback forms** you still want (`http://localhost:PORT`, `http://127.0.0.1:PORT`, `http://[::1]:PORT`) — otherwise local access stops working. A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off. @@ -176,7 +178,7 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`. -**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today. +**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port — `MCP_SANDBOX_PORT`, defaulting to a fixed **`6275`** (#2008; it was OS-assigned before, which meant it changed every run and so could never be named in a `forwardPorts` / `-p` / tunnel config written ahead of time). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — expose/forward `6275` alongside `6274`, or set `MCP_SANDBOX_PORT` to pick another. If the port is already taken the sandbox falls back to an OS-assigned one and warns, so a second Inspector still gets a working Apps tab locally — but the forwarded port is then wrong, which is what the warning tells you. (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today. In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard. diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index a6d6fda1..f18945ce 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -26,21 +26,46 @@ function isEnabled(value: string | undefined): boolean { return v === "true" || v === "1"; } +/** + * The default bind host: the IPv4 loopback **address**, deliberately not the + * name `localhost`. + * + * `server.listen(port, host)` resolves a name through `dns.lookup` and binds the + * **single** address it gets back. Since Node 17 the default result order is + * `verbatim`, so Node takes whatever the resolver returns first rather than + * preferring IPv4 — and on a glibc Linux host with the stock `/etc/hosts` + * (`127.0.0.1 localhost` *and* `::1 localhost`) that is `::1`. `HOST=localhost` + * therefore binds IPv6 loopback **only**, and every IPv4 client is refused. + * + * That is invisible on a desktop, where the browser resolves `localhost` the + * same way and lands on `::1` too. It breaks wherever the connection is made by + * something that pins IPv4 `127.0.0.1` instead — a VS Code dev container's port + * forwarder (#1951), an `ssh -L …:127.0.0.1:…` tunnel, a container healthcheck. + * Binding the address directly removes the resolver from the decision, and the + * loopback origin allow-list already covers `localhost`, `127.0.0.1`, and + * `[::1]` alike, so browsing to `http://localhost:PORT` still works (browsers + * fall back across address families; a Node `listen` does not). + */ +export const DEFAULT_BIND_HOST = "127.0.0.1"; + /** * Resolve the bind hostname from `env` (default `process.env`), defaulting to - * `localhost`. Refuses an all-interfaces host (`0.0.0.0` / `::` / empty / their - * legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is explicitly - * enabled — the published Docker image sets it, since a container must bind - * `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly) rather than - * silently binding wide open. The returned value is trimmed and de-bracketed - * (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so detection, - * `listen()`, and the origin list all consume the same value; `formatHostForUrl` - * re-adds the brackets wherever a URL is built. + * {@link DEFAULT_BIND_HOST}. Refuses an all-interfaces host (`0.0.0.0` / `::` / + * empty / their legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is + * explicitly enabled — the published Docker image sets it, since a container + * must bind `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly) + * rather than silently binding wide open. The returned value is trimmed and + * de-bracketed (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so + * detection, `listen()`, and the origin list all consume the same value; + * `formatHostForUrl` re-adds the brackets wherever a URL is built. + * + * An explicit `HOST=localhost` is honored as typed — the single-family bind + * described on {@link DEFAULT_BIND_HOST} is then the caller's choice, not ours. */ export function resolveBindHostname( env: NodeJS.ProcessEnv = process.env, ): string { - const host = (env.HOST ?? "localhost").trim(); + const host = (env.HOST ?? DEFAULT_BIND_HOST).trim(); if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) { // Show the resolved address when it differs from the typed spelling — the // guard now catches forms the resolver folds to the wildcard (a fullwidth diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index e4b0d53e..50d4c440 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -11,13 +11,14 @@ import { canonicalUrlHost, isAllInterfacesHost, } from "../../../core/node/hostUrl.ts"; +import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); export interface SandboxControllerOptions { /** Port to bind (0 = dynamic). */ port: number; - /** Host to bind (default localhost). */ + /** Host to bind (default {@link DEFAULT_BIND_HOST}). */ host?: string; /** * The backend's origin allow-list (the embedder origins). Used to build the @@ -95,10 +96,28 @@ function parseListenPort(raw: string | undefined): number | undefined { } /** - * Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic). - * An invalid value falls through rather than crashing the boot; a set-but-invalid - * MCP_SANDBOX_PORT (the dedicated knob) is warned so the fall-through isn't - * silent — matching the warn-and-drop precedent for ALLOWED_ORIGINS. + * The default sandbox listen port — **fixed**, deliberately not `0`. + * + * A `0` default lets the OS assign, which is fine on a desktop (the browser + * learns the port from `/api/config`) but unusable anywhere the port has to be + * declared *before* the process starts: a dev container's `forwardPorts`, a + * `docker run -p`, a k8s service, an SSH tunnel. The port changed on every run, + * so it could never be named in that config and the Apps tab was unreachable + * (#2008). `CLIENT_PORT` has always defaulted to a fixed `6274` for the same + * reason; this is its sandbox counterpart. + * + * `6275` sits next to the web port so the pair is easy to forward together. It + * is not a v1 carry-over — v2 has no proxy server on it. + */ +export const DEFAULT_SANDBOX_PORT = 6275; + +/** + * Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → + * {@link DEFAULT_SANDBOX_PORT}. An invalid value falls through rather than + * crashing the boot; a set-but-invalid MCP_SANDBOX_PORT (the dedicated knob) is + * warned so the fall-through isn't silent — matching the warn-and-drop + * precedent for ALLOWED_ORIGINS. An explicit `0` is still honored as "let the + * OS assign" ({@link parseListenPort} accepts it). */ export function resolveSandboxPort(): number { const fromSandbox = parseListenPort(process.env.MCP_SANDBOX_PORT); @@ -107,13 +126,21 @@ export function resolveSandboxPort(): number { `Ignoring invalid MCP_SANDBOX_PORT="${process.env.MCP_SANDBOX_PORT}" (need an integer 0–65535); falling back.`, ); } - return fromSandbox ?? parseListenPort(process.env.SERVER_PORT) ?? 0; + return ( + fromSandbox ?? + parseListenPort(process.env.SERVER_PORT) ?? + DEFAULT_SANDBOX_PORT + ); } export function createSandboxController( options: SandboxControllerOptions, ): SandboxController { - const { port, host = "localhost", allowedOrigins } = options; + // Defaulted to the same address the web server binds, never the name + // `localhost` — a name resolves to one address family and would reintroduce + // the #1951 split (web on IPv4, sandbox on IPv6) for any future call site + // that omits `host`. Both call sites pass `config.sandboxHost` today. + const { port, host = DEFAULT_BIND_HOST, allowedOrigins } = options; let server: Server | null = null; let sandboxUrl: string | null = null; @@ -177,8 +204,28 @@ export function createSandboxController( }); res.end(sandboxHtml); }); + // A taken port used to mean "no sandbox at all", which was tolerable + // only because the default was dynamic and therefore never collided. + // Now that it's fixed (see DEFAULT_SANDBOX_PORT), a second Inspector — + // or anything else already on 6275 — would take the Apps tab down, so + // retry once on an OS-assigned port. The retry is announced loudly: + // whoever pinned MCP_SANDBOX_PORT to forward it needs to know the pin + // didn't take, and a silently-moved port is exactly the failure this + // default was introduced to fix. + let retriedDynamic = false; server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { + if (!retriedDynamic && port !== 0) { + retriedDynamic = true; + console.warn( + `Sandbox: port ${port} in use; falling back to an OS-assigned port. ` + + `The MCP Apps tab will work locally, but the sandbox is no longer ` + + `on a predictable port — set MCP_SANDBOX_PORT to a free one if you ` + + `need to forward it (container / SSH tunnel / reverse proxy).`, + ); + server!.listen(0, host); + return; + } console.error( `Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`, ); diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts index c6de0ada..691230a1 100644 --- a/clients/web/src/test/integration/server/resolve-bind-host.test.ts +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -1,12 +1,24 @@ import { describe, it, expect } from "vitest"; import { BIND_ALL_INTERFACES_ENV, + DEFAULT_BIND_HOST, resolveBindHostname, } from "../../../../server/resolve-bind-host.js"; describe("resolveBindHostname", () => { - it("defaults to localhost when HOST is unset", () => { - expect(resolveBindHostname({})).toBe("localhost"); + // Regression guard for #1951. The default must be an *address*, never the + // name `localhost`: `listen()` resolves a name and binds the single address + // the resolver returns first, which on glibc Linux is `::1` — leaving every + // IPv4 client (a dev container's port forwarder, an `ssh -L` to 127.0.0.1, a + // container healthcheck) refused. See DEFAULT_BIND_HOST. + it("defaults to the IPv4 loopback address, not the name localhost", () => { + expect(resolveBindHostname({})).toBe("127.0.0.1"); + expect(resolveBindHostname({})).toBe(DEFAULT_BIND_HOST); + expect(resolveBindHostname({})).not.toBe("localhost"); + }); + + it("still honors an explicit HOST=localhost as typed", () => { + expect(resolveBindHostname({ HOST: "localhost" })).toBe("localhost"); }); it("returns a loopback HOST unchanged", () => { diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index 5db8d1b3..a1f73f7e 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createServer, type Server } from "node:http"; import { createSandboxController, + DEFAULT_SANDBOX_PORT, resolveSandboxPort, sandboxFrameAncestors, } from "../../../../server/sandbox-controller.js"; @@ -75,7 +76,17 @@ describe("resolveSandboxPort", () => { else process.env.SERVER_PORT = envSnapshot.server; }); - it("returns 0 when no env vars are set", () => { + // Regression guard for #2008: the default must be a FIXED port. An + // OS-assigned one changes every run, so it can never be named in a dev + // container's `forwardPorts`, a `docker run -p`, or an SSH tunnel — leaving + // the Apps tab unreachable in every such environment. + it("returns a fixed default port when no env vars are set", () => { + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); + expect(resolveSandboxPort()).not.toBe(0); + }); + + it("still honors an explicit MCP_SANDBOX_PORT=0 as OS-assigned", () => { + process.env.MCP_SANDBOX_PORT = "0"; expect(resolveSandboxPort()).toBe(0); }); @@ -102,19 +113,19 @@ describe("resolveSandboxPort", () => { expect(resolveSandboxPort()).toBe(9100); }); - it("returns 0 when SERVER_PORT is non-numeric and no MCP_SANDBOX_PORT", () => { + it("falls back to the default when SERVER_PORT is non-numeric and no MCP_SANDBOX_PORT", () => { process.env.SERVER_PORT = "not-a-port"; - expect(resolveSandboxPort()).toBe(0); + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); }); it("ignores empty-string SERVER_PORT", () => { process.env.SERVER_PORT = ""; - expect(resolveSandboxPort()).toBe(0); + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); }); it("ignores negative values", () => { process.env.MCP_SANDBOX_PORT = "-1"; - expect(resolveSandboxPort()).toBe(0); + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); }); it("rejects a partial-parse value (6274abc) rather than binding 6274", () => { @@ -125,7 +136,7 @@ describe("resolveSandboxPort", () => { it("rejects an out-of-range value (70000) so it can't crash listen", () => { process.env.MCP_SANDBOX_PORT = "70000"; - expect(resolveSandboxPort()).toBe(0); + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); }); }); @@ -135,7 +146,8 @@ describe("createSandboxController", () => { try { const { url, port } = await controller.start(); expect(port).toBeGreaterThan(0); - expect(url).toBe(`http://localhost:${port}/sandbox`); + // Default host is now the IPv4 loopback address, not the name (#1951). + expect(url).toBe(`http://127.0.0.1:${port}/sandbox`); expect(controller.getUrl()).toBe(url); const res = await fetch(url); @@ -312,11 +324,11 @@ describe("createSandboxController", () => { } }); - it("resolves with empty values when listen fails (EADDRINUSE)", async () => { - // Bind a placeholder HTTP server to claim a port, then point a sandbox - // controller at the same port to force EADDRINUSE. The Vite plugin awaits - // start() in configureServer; if start() ever stops resolving, the entire - // dev backend hangs. This test pins down the resolve-on-error contract. + /** Claim a port so a subsequent listen on it fails with EADDRINUSE. */ + async function claimPort(): Promise<{ + port: number; + release: () => Promise; + }> { const blocker: Server = createServer(); await new Promise((resolve) => blocker.listen(0, "127.0.0.1", () => resolve()), @@ -327,21 +339,96 @@ describe("createSandboxController", () => { ? addr.port : 0; expect(port).toBeGreaterThan(0); + return { + port, + release: () => + new Promise((resolve) => blocker.close(() => resolve())), + }; + } + it("falls back to an OS-assigned port when the fixed port is taken", async () => { + // #2008 made the default port fixed, which introduced a collision the + // dynamic default never had: a second Inspector, or anything else already + // on 6275, would have taken the Apps tab down entirely. Retrying on a + // dynamic port keeps it working locally. + const { port, release } = await claimPort(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const controller = createSandboxController({ port, host: "127.0.0.1" }); + try { + const result = await controller.start(); + expect(result.port).toBeGreaterThan(0); + expect(result.port).not.toBe(port); + expect(result.url).toBe(`http://127.0.0.1:${result.port}/sandbox`); + expect(controller.getUrl()).toBe(result.url); + // The sandbox must actually be serving on the fallback port, not merely + // reporting one. + expect((await fetch(result.url)).status).toBe(200); + // Loud, not silent: a pinned-and-forwarded port that quietly moved is the + // very failure the fixed default exists to prevent. + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining(`Sandbox: port ${port} in use`), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("MCP_SANDBOX_PORT"), + ); + } finally { + warnSpy.mockRestore(); + await controller.close(); + await release(); + } + }); + + it("resolves with empty values when even the dynamic retry fails", async () => { + // The Vite plugin awaits start() in configureServer; if start() ever stops + // resolving, the entire dev backend hangs. This pins the resolve-on-error + // contract for the case the retry can't rescue — an explicit `port: 0` that + // fails has nowhere left to fall back to, so it degrades as before. + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const controller = createSandboxController({ port: 0, host: "127.0.0.1" }); + try { + // Force the listen to fail the way a dynamic listen can (an unusable bind + // address), since port 0 cannot collide. + const broken = createSandboxController({ + port: 0, + host: "203.0.113.1", // TEST-NET-3: not assigned to any local interface + }); + const result = await broken.start(); + expect(result).toEqual({ port: 0, url: "" }); + expect(broken.getUrl()).toBeNull(); + // close() must be a no-op since the server never bound. + await expect(broken.close()).resolves.toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + "Sandbox server error:", + expect.objectContaining({ code: "EADDRNOTAVAIL" }), + ); + } finally { + errorSpy.mockRestore(); + await controller.close(); + } + }); + + it("gives up after one retry when the dynamic fallback also fails", async () => { + // Guards the `retriedDynamic` latch: without it an EADDRINUSE loop could + // re-listen forever instead of settling. + const { port, release } = await claimPort(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const controller = createSandboxController({ port, host: "127.0.0.1" }); try { const result = await controller.start(); - expect(result).toEqual({ port: 0, url: "" }); - expect(controller.getUrl()).toBeNull(); - // close() must be a no-op since the server never bound. - await expect(controller.close()).resolves.toBeUndefined(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining(`Sandbox: port ${port} in use`), - ); + // The retry succeeds here (a dynamic port is always available), so assert + // the latch is what bounds it: exactly one retry warning was emitted. + expect( + warnSpy.mock.calls.filter((c) => + String(c[0]).includes("falling back to an OS-assigned port"), + ), + ).toHaveLength(1); + expect(result.port).toBeGreaterThan(0); } finally { + warnSpy.mockRestore(); errorSpy.mockRestore(); - await new Promise((resolve) => blocker.close(() => resolve())); + await controller.close(); + await release(); } }); diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index 5436b8fe..7dbb2e4a 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -7,6 +7,7 @@ import { webServerConfigToInitialPayload, type WebServerConfig, } from "../../../../server/web-server-config.js"; +import { DEFAULT_SANDBOX_PORT } from "../../../../server/sandbox-controller.js"; import { API_SERVER_ENV_VARS, LEGACY_AUTH_TOKEN_ENV, @@ -32,7 +33,7 @@ const MUTATED_ENV_KEYS = [ const baseConfig = (): WebServerConfig => ({ port: 6274, - hostname: "localhost", + hostname: "127.0.0.1", authToken: "tok", dangerouslyOmitAuth: false, initialMcpConfig: null, @@ -42,7 +43,7 @@ const baseConfig = (): WebServerConfig => ({ storageDir: undefined, allowedOrigins: ["http://localhost:6274"], sandboxPort: 0, - sandboxHost: "localhost", + sandboxHost: "127.0.0.1", logger: undefined, autoOpen: false, }); @@ -72,7 +73,7 @@ describe("buildWebServerConfigFromEnv", () => { it("returns defaults when no env vars are set", () => { const cfg = buildWebServerConfigFromEnv(); expect(cfg.port).toBe(6274); - expect(cfg.hostname).toBe("localhost"); + expect(cfg.hostname).toBe("127.0.0.1"); expect(cfg.authToken).toBe(""); expect(cfg.dangerouslyOmitAuth).toBe(false); expect(cfg.initialMcpConfig).toBeNull(); @@ -82,8 +83,8 @@ describe("buildWebServerConfigFromEnv", () => { "http://127.0.0.1:6274", "http://[::1]:6274", ]); - expect(cfg.sandboxPort).toBe(0); - expect(cfg.sandboxHost).toBe("localhost"); + expect(cfg.sandboxPort).toBe(DEFAULT_SANDBOX_PORT); + expect(cfg.sandboxHost).toBe("127.0.0.1"); expect(cfg.logger).toBeUndefined(); // Vitest sets `process.env.VITEST = 'true'`, so the autoOpen default is // suppressed here. Real `vite dev` runs don't set VITEST and default to @@ -289,7 +290,9 @@ describe("buildWebServerConfigFromEnv", () => { it("ignores non-numeric SERVER_PORT", () => { process.env.SERVER_PORT = "nope"; - expect(buildWebServerConfigFromEnv().sandboxPort).toBe(0); + expect(buildWebServerConfigFromEnv().sandboxPort).toBe( + DEFAULT_SANDBOX_PORT, + ); }); it("treats MCP_SANDBOX_PORT empty string as unset", () => { @@ -596,7 +599,7 @@ describe("printServerBanner", () => { it("includes the auth token in the URL when auth is enabled", () => { const url = printServerBanner(baseConfig(), 6274, "secret", undefined); expect(url).toBe( - `http://localhost:6274?${API_SERVER_ENV_VARS.AUTH_TOKEN}=secret`, + `http://127.0.0.1:6274?${API_SERVER_ENV_VARS.AUTH_TOKEN}=secret`, ); expect(logSpy.lines.some((l) => l.includes("Auth token: secret"))).toBe( true, @@ -607,7 +610,7 @@ describe("printServerBanner", () => { const cfg = baseConfig(); cfg.dangerouslyOmitAuth = true; const url = printServerBanner(cfg, 6274, "irrelevant", undefined); - expect(url).toBe("http://localhost:6274"); + expect(url).toBe("http://127.0.0.1:6274"); expect( logSpy.lines.some((l) => l.includes("Auth: disabled (DANGEROUSLY_OMIT_AUTH)"), @@ -617,7 +620,7 @@ describe("printServerBanner", () => { it("omits the query string when no token is supplied", () => { const url = printServerBanner(baseConfig(), 6274, "", undefined); - expect(url).toBe("http://localhost:6274"); + expect(url).toBe("http://127.0.0.1:6274"); }); it("brackets an IPv6 bind host in the printed URL", () => { diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index a2db3f55..0d1b2fe3 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -66,7 +66,7 @@ Consequences: - `MCP_PROXY_FULL_ADDRESS` has no v2 equivalent and is ignored. It existed to tell the browser where a non-default proxy lived; there is no proxy. - Docker needs `-p 6274:6274` for the UI, where the v1 recipe published `6277` as well. -⚠️ **`6274` is not the only listener.** The web backend also starts a **separate MCP Apps sandbox server**, on a dynamic port by default. It is only used by the Apps tab, and on plain loopback it needs no attention — but the browser reaches it directly, so anywhere the Inspector is _not_ served from the browser's own machine (Docker, a remote host, an SSH tunnel) you must pin it with `MCP_SANDBOX_PORT` and expose/forward that port too, or the Apps tab won't load. This is a different port from v1's proxy — it carries no MCP traffic — but it is still a second port. See [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). +⚠️ **`6274` is not the only listener.** The web backend also starts a **separate MCP Apps sandbox server**, on a fixed **`6275`** by default (it was OS-assigned before #2008). It is only used by the Apps tab, and on plain loopback it needs no attention — but the browser reaches it directly, so anywhere the Inspector is _not_ served from the browser's own machine (Docker, a dev container, a remote host, an SSH tunnel) you must expose/forward that port too, or the Apps tab won't load. This is a different port from v1's proxy — it carries no MCP traffic — but it is still a second port. See [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). ## Launching @@ -280,7 +280,7 @@ Stdout is otherwise compatible: the default `text` format still pretty-prints th | `MCP_AUTO_OPEN_ENABLED` | same | Also gates non-TTY interactive OAuth in the CLI/TUI | | — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) | | — | `MCP_CATALOG_PATH` | Default catalog path | -| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (dynamic by default) | +| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (fixed `6275` by default) | | — | `MCP_STORAGE_DIR`, `MCP_INSPECTOR_OAUTH_STATE_PATH`, `MCP_CLIENT_CONFIG_PATH` | Storage and OAuth state locations | | — | `MCP_OAUTH_CALLBACK_URL` | CLI/TUI OAuth loopback callback | @@ -355,7 +355,9 @@ Notes: **"`Transport type not specified and could not be determined from URL`."** The path ends in neither `/mcp` nor `/sse`, so v2 refuses to guess where v1 fell back to SSE. Pass `--transport http` or `--transport sse`. -**"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second, dynamic port. Pin it with `MCP_SANDBOX_PORT` and publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). +**"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second port (`6275` by default). Publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). + +**"The page never loads inside a VS Code dev container (or any container)."** Fixed in the release carrying #1951 — upgrade. Before that fix the default `HOST=localhost` bound IPv6 loopback only on Linux, so the container's port forwarder (which connects over IPv4 `127.0.0.1`) could never reach it; the terminal printed a normal banner while the browser spun forever. On an older version, work around it with `HOST=127.0.0.1`. Note the failure is a **hang**, not a connection error, because the forwarder accepts on the host side before failing to connect inward. **"`HOST=0.0.0.0` exits with an error."** That's the wildcard-bind guard. Bind a specific address instead, or set `DANGEROUSLY_BIND_ALL_INTERFACES=true` if you genuinely need all interfaces. See [Host binding & the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list). From 1ea1e30f6344985927cf68657bd583dbf7f928cf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 13:17:08 -0400 Subject: [PATCH 07/58] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20real=20latch=20coverage,=20consistent=20docker=20do?= =?UTF-8?q?cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The `retriedDynamic` latch test never made the retry fail: a real `listen(0)` always succeeds, so it produced one EADDRINUSE and passed with the latch deleted. Mock `node:http` so every listen fails, asserting exactly two listen attempts and a settled result. Verified by mutation: removing the latch now hangs the test instead of passing it. - The migration guide's Docker notes still described a dynamic sandbox port and told readers to pin a custom one, contradicting the new fixed 6275 two sections later. - Narrowed "or any container" in the troubleshooting entry, which overstated the scope and contradicted the PR's own scope note. It now names the IPv4-forwarder case and explicitly excludes the official image and a plain loopback -p. Signed-off-by: cliffhall --- .../server/sandbox-controller.test.ts | 58 ++++++++++++++++--- docs/v1-to-v2-migration.md | 9 +-- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index a1f73f7e..6614c06e 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createServer, type Server } from "node:http"; +import { EventEmitter } from "node:events"; import { createSandboxController, DEFAULT_SANDBOX_PORT, @@ -408,27 +409,66 @@ describe("createSandboxController", () => { }); it("gives up after one retry when the dynamic fallback also fails", async () => { - // Guards the `retriedDynamic` latch: without it an EADDRINUSE loop could - // re-listen forever instead of settling. - const { port, release } = await claimPort(); + // Guards the `retriedDynamic` latch. A real `listen(0)` effectively always + // succeeds, so claiming a port only ever produces ONE EADDRINUSE — under + // which this test would pass with the latch deleted. Mock `node:http` so + // every listen fails with EADDRINUSE: the first trips the retry, the second + // must be caught by the latch and settle. Without the latch this recurses + // until the test times out rather than resolving. + vi.resetModules(); + const listenCalls: number[] = []; + vi.doMock("node:http", async () => { + const actual = + await vi.importActual("node:http"); + return { + ...actual, + createServer: () => { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + listen: (p: number) => { + listenCalls.push(p); + setImmediate(() => + emitter.emit( + "error", + Object.assign(new Error("in use"), { code: "EADDRINUSE" }), + ), + ); + }, + address: () => null, + close: (cb?: () => void) => cb?.(), + }); + }, + }; + }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const controller = createSandboxController({ port, host: "127.0.0.1" }); try { + const mod = await import("../../../../server/sandbox-controller.js"); + const controller = mod.createSandboxController({ + port: 6275, + host: "127.0.0.1", + }); + // Settles rather than looping — the contract the Vite plugin depends on. const result = await controller.start(); - // The retry succeeds here (a dynamic port is always available), so assert - // the latch is what bounds it: exactly one retry warning was emitted. + expect(result).toEqual({ port: 0, url: "" }); + expect(controller.getUrl()).toBeNull(); + // Exactly two listens: the fixed port, then one dynamic retry. A third + // would mean the latch failed to bound the loop. + expect(listenCalls).toEqual([6275, 0]); expect( warnSpy.mock.calls.filter((c) => String(c[0]).includes("falling back to an OS-assigned port"), ), ).toHaveLength(1); - expect(result.port).toBeGreaterThan(0); + // The second failure degrades loudly instead of retrying again. + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Sandbox: port 6275 in use"), + ); } finally { warnSpy.mockRestore(); errorSpy.mockRestore(); - await controller.close(); - await release(); + vi.doUnmock("node:http"); + vi.resetModules(); } }); diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 0d1b2fe3..c241e9df 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -330,14 +330,15 @@ docker run --rm -p 127.0.0.1:6274:6274 ghcr.io/modelcontextprotocol/inspector:la Notes: - **Keep the `127.0.0.1:` prefix on the published port**, as the v1 recipe did. `-p 6274:6274` publishes on every host interface, and the container's `HOST=0.0.0.0` is about the _container's_ interfaces, not the host's — the two are independent. That matters here because the backend spawns processes, `/` embeds the API token, and requests arriving with **no** `Origin` header (i.e. anything that isn't a browser) skip the origin allow-list, leaving the token as the only guard. Bind to loopback and opt into wider exposure deliberately. -- **The Apps tab needs one more published port.** The sandbox server is dynamic by default and the `Dockerfile` `EXPOSE`s only `6274`, so the recipe above covers everything _except_ MCP Apps. To use them, pin and publish the sandbox port as well: +- **The Apps tab needs one more published port.** The sandbox server listens on `6275` by default, so the recipe above covers everything _except_ MCP Apps. To use them, publish that port as well: ```bash - docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6280:6280 \ - -e MCP_SANDBOX_PORT=6280 \ + docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \ ghcr.io/modelcontextprotocol/inspector:latest ``` + Publish it on the **same port number** inside and out: the sandbox URL is handed to the browser via `/api/config` as `http://localhost:/sandbox`, so remapping it advertises a port the browser can't reach. To use a different number, move both ends — `-e MCP_SANDBOX_PORT=6280 -p 127.0.0.1:6280:6280`. + - The v2 image sets `DANGEROUSLY_BIND_ALL_INTERFACES=true` internally (a container must bind `0.0.0.0` to be reachable through `-p`). Setting a bare `HOST=0.0.0.0` **outside** a container now exits with an error. - **If you remap the published port** (`-p 8080:6274`), the browser's origin no longer matches the in-container port, so set `ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. - The image runs as non-root and has a `HEALTHCHECK` that assumes `--web`; add `--no-healthcheck` when running `--cli` / `--tui`. @@ -357,7 +358,7 @@ Notes: **"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second port (`6275` by default). Publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). -**"The page never loads inside a VS Code dev container (or any container)."** Fixed in the release carrying #1951 — upgrade. Before that fix the default `HOST=localhost` bound IPv6 loopback only on Linux, so the container's port forwarder (which connects over IPv4 `127.0.0.1`) could never reach it; the terminal printed a normal banner while the browser spun forever. On an older version, work around it with `HOST=127.0.0.1`. Note the failure is a **hang**, not a connection error, because the forwarder accepts on the host side before failing to connect inward. +**"The page never loads inside a VS Code dev container."** Fixed in the release carrying #1951 — upgrade; on an older version, work around it with `HOST=127.0.0.1`. Before that fix the default `HOST=localhost` bound IPv6 loopback **only** on Linux, so anything connecting over IPv4 `127.0.0.1` was refused — which is what a dev container's port forwarder does. The terminal printed a normal banner while the browser spun forever; the failure is a **hang** rather than a connection error because the forwarder accepts on the host side before failing to connect inward. The same fix covers an `ssh -L 6274:127.0.0.1:6274` tunnel and a container healthcheck. It does **not** describe the official Docker image (it binds `0.0.0.0`, so it was never affected) or a plain `docker run -p` against a loopback bind, which is unreachable across the network namespace for an unrelated reason. **"`HOST=0.0.0.0` exits with an error."** That's the wildcard-bind guard. Bind a specific address instead, or set `DANGEROUSLY_BIND_ALL_INTERFACES=true` if you genuinely need all interfaces. See [Host binding & the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list). From a9ed087f253d35d79b603e45bac2ea816773a7c0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 14:16:03 -0400 Subject: [PATCH 08/58] chore: sync v2/main to 2.2.0 and bump on v2/main before the milestone merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2/main has been at 2.0.0 since the v2.0.0 release while main and npm `latest` are at 2.2.0. The bump was created on the milestone-merge branch, which is cut from main, so it only ever existed downstream of v2/main and nothing carried it back — `chore: 2.1.0` and `chore: 2.2.0` are both on main and neither is on v2/main. That is not cosmetic. A branch cut from a milestone-merge branch silently carries the bump into an unrelated PR — #2009 arrived with a 2.0.0 -> 2.2.0 diff on a container bugfix — and anything reading the version in development (readInspectorVersion, --version, GET /api/config) reported a version two releases old. Move the bump to v2/main, before the milestone merge, so it flows into main with the rest of the milestone's work and the branches never disagree. The release tag is applied to the resulting main commit instead. The publish workflow needs no change: its tag-vs-package.json assertion runs at the release commit on main, which still carries the bump. Deliberately not a back-merge of main into v2/main: main holds the whole pre-v2 v1 history (~230 commits v2/main lacks, retained through `chore: replace main's tree with v2`), so merging would graft all of it into the develop branch's log permanently to deliver a two-file change. Also sets v2/main to 2.2.0 as a one-time catch-up. Nothing in the repo asserts a specific version value, so this is a safe two-file change. Closes #2010 Signed-off-by: cliffhall --- AGENTS.md | 4 ++++ README.md | 24 +++++++++++++++++++++--- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4123ccad..9ac732b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -465,6 +465,10 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor So v2 flows `feature branch → v2/main → (milestone) main → npm latest`, while v1 is flat: `feature branch → v1/main → npm v1-latest`, with no merge into `main` at any point. The two lines are published independently under separate dist-tags, which is why a v1 fix does **not** need to be forward-ported to reach users on v1 (`npx @modelcontextprotocol/inspector@v1-latest`). + ⚠️ **The version bump rides that same flow — it is made on `v2/main`, never on the milestone-merge branch (#2010).** `npm version --no-git-tag-version` lands on `v2/main` *before* the milestone merge, so the bump reaches `main` as part of the milestone's work and the two branches never disagree about the version; the release tag is then applied to the resulting **`main`** commit. Bumping on the milestone-merge branch instead (which is cut from `main`) puts the bump only downstream of `v2/main`, where nothing carries it back — that is how `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases, and how #2009 came to carry an unrelated `2.0.0 → 2.2.0` diff simply because its branch was cut from a milestone-merge branch. **Never close a drift by merging `main` into `v2/main`**: `main` holds the whole pre-v2 v1 history (~230 commits `v2/main` lacks), so a back-merge grafts all of it into the develop branch permanently to deliver a two-file change. Full procedure in the root README's [Cutting a release](./README.md#cutting-a-release). + + A corollary for **branching**: cut feature branches from **`v2/main`**, never from a milestone-merge branch — the latter carries release-only commits that will show up in your PR's diff. + - **Project Boards**: - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all new work goes here) - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (legacy inspector version, no new activity except security fixes) diff --git a/README.md b/README.md index 35b1ce6d..9c9f59ee 100644 --- a/README.md +++ b/README.md @@ -326,16 +326,34 @@ Publishing is automated by two release-gated jobs in [`.github/workflows/main.ym A v2 release is cut from **`main`**, after the milestone's work has been merged there from `v2/main` — not from `v2/main` itself. (The v1 line releases independently from `v1/main` to the `v1-latest` tag and never touches `main`; see [Repo status](#mcp-inspector).) -Because there is **one version number** (only the root `package.json` has one — the clients carry none, so there is nothing to keep in sync and no `check-version` step), the release flow is just: +Because there is **one version number** (only the root `package.json` has one — the clients carry none, so there is nothing to keep in sync and no `check-version` step), the release flow is three steps. + +**1. Bump on `v2/main`, before the milestone merge.** The bump is part of the milestone's work, so it belongs on the develop branch and flows into `main` with everything else: ```bash -npm version # bumps the root package.json + tags -git push --follow-tags +git checkout -b v2/chore/-bump-x-y-z v2/main +npm version --no-git-tag-version # bump only, no tag +# PR → v2/main +``` + +⚠️ **`--no-git-tag-version` is load-bearing.** A bare `npm version` also tags, and the tag would land on a `v2/main` commit — but the release must be cut from `main`, so the tag has to point at the merge commit there (step 3). Tagging here creates a tag on a commit that is never released. + +**2. Merge `v2/main` → `main`** through the usual milestone-merge branch. It now carries the bump, so `main` and `v2/main` agree on the version at every point. + +**3. Tag the `main` commit and draft the Release:** + +```bash +git checkout main && git pull +git tag v && git push origin v # then draft & publish a GitHub Release for that tag → triggers `publish` ``` The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow. +**Why the bump goes on `v2/main` first ([#2010](https://github.com/modelcontextprotocol/inspector/issues/2010)).** It used to happen on the milestone-merge branch, which is cut from `main` — so the bump existed only *downstream* of `v2/main` and nothing carried it back. `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases. That is not cosmetic: a branch cut from a milestone-merge branch silently carries the bump into an unrelated PR (this happened on [#2009](https://github.com/modelcontextprotocol/inspector/issues/2009), where a container bugfix arrived with a `2.0.0 → 2.2.0` diff), and anything reading the version in development — `readInspectorVersion()`, `--version`, `GET /api/config` — reported a version two releases old. + +Do **not** "fix" a future drift by merging `main` back into `v2/main`. `main` carries the entire pre-v2 v1 history (retained through `ec5d8e13 chore: replace main's tree with v2` — ~230 commits `v2/main` does not have), so a back-merge grafts all of it into the develop branch's log permanently in order to deliver a two-file change. Bumping first means there is nothing to back-merge. + ### Docker A container image is published to GHCR (`ghcr.io/modelcontextprotocol/inspector`, `linux/amd64` + `linux/arm64`) by the release workflow. The [`Dockerfile`](Dockerfile) is a two-stage build: the first stage installs and `npm pack`s the publishable tarball; the second stage `npm install -g`s that tarball, so the image ships the exact same artifact as npm, with a clean `mcp-inspector` bin. diff --git a/package-lock.json b/package-lock.json index 5b738bc6..bb4866ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0", + "version": "2.2.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b255bb28..4e0295ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0", + "version": "2.2.0", "description": "The Model Context Protocol Inspector", "keywords": [ "MCP", From 9048e69758134145214d0bd8f1229ba6e7d9ffe6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 14:39:30 -0400 Subject: [PATCH 09/58] docs: drop the v prefix from the release tag step This repo's release tags are bare x.y.z (2.2.0, 2.1.0, 2.0.0), not v-prefixed. Also notes why the manual tag matters: npm's tag-version-prefix defaults to 'v' and the repo sets no .npmrc, so a bare `npm version` would have produced a tag that doesn't match the convention. Signed-off-by: cliffhall --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9c9f59ee..f90ed822 100644 --- a/README.md +++ b/README.md @@ -344,10 +344,12 @@ npm version --no-git-tag-version # bump only, no tag ```bash git checkout main && git pull -git tag v && git push origin v +git tag && git push origin # then draft & publish a GitHub Release for that tag → triggers `publish` ``` +⚠️ **No `v` prefix.** This repo's release tags are bare `x.y.z` — `2.2.0`, `2.1.0`, `2.0.0` — so tag `2.3.0`, not `v2.3.0`. Note npm's own `tag-version-prefix` defaults to `v` and the repo sets no `.npmrc`, so a bare `npm version` would have produced a `v`-prefixed tag that does not match the convention. Tagging by hand (step 3) is what keeps it right. The workflow's assert step strips a leading `v` before comparing, so a `v`-prefixed tag would still publish — it would just be inconsistent with every previous release. + The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow. **Why the bump goes on `v2/main` first ([#2010](https://github.com/modelcontextprotocol/inspector/issues/2010)).** It used to happen on the milestone-merge branch, which is cut from `main` — so the bump existed only *downstream* of `v2/main` and nothing carried it back. `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases. That is not cosmetic: a branch cut from a milestone-merge branch silently carries the bump into an unrelated PR (this happened on [#2009](https://github.com/modelcontextprotocol/inspector/issues/2009), where a container bugfix arrived with a `2.0.0 → 2.2.0` diff), and anything reading the version in development — `readInspectorVersion()`, `--version`, `GET /api/config` — reported a version two releases old. From 4d567c97e4555c357e010340287b26aa2a489c05 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 14:47:33 -0400 Subject: [PATCH 10/58] docs: make the release commands copy-pasteable and correct the invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: - `` / `` inside a bash block is shell redirection, so both documented commands were syntax errors if copied. Verified: `bash -n` rejects `git tag && ...` and accepts `git tag 2.3.0 && ...`. Use a concrete 2.2.0 -> 2.3.0 example and say what to substitute. - "agree on the version at every point" / "never disagree" overstated it. The procedure deliberately bumps v2/main first, so between the bump and the milestone merge the branches differ — v2/main reads the version being built, main the one released. Describe the real invariant: they converge when the release merge lands, and v2/main is never left behind main. Signed-off-by: cliffhall --- AGENTS.md | 2 +- README.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9ac732b8..0baea459 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -465,7 +465,7 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor So v2 flows `feature branch → v2/main → (milestone) main → npm latest`, while v1 is flat: `feature branch → v1/main → npm v1-latest`, with no merge into `main` at any point. The two lines are published independently under separate dist-tags, which is why a v1 fix does **not** need to be forward-ported to reach users on v1 (`npx @modelcontextprotocol/inspector@v1-latest`). - ⚠️ **The version bump rides that same flow — it is made on `v2/main`, never on the milestone-merge branch (#2010).** `npm version --no-git-tag-version` lands on `v2/main` *before* the milestone merge, so the bump reaches `main` as part of the milestone's work and the two branches never disagree about the version; the release tag is then applied to the resulting **`main`** commit. Bumping on the milestone-merge branch instead (which is cut from `main`) puts the bump only downstream of `v2/main`, where nothing carries it back — that is how `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases, and how #2009 came to carry an unrelated `2.0.0 → 2.2.0` diff simply because its branch was cut from a milestone-merge branch. **Never close a drift by merging `main` into `v2/main`**: `main` holds the whole pre-v2 v1 history (~230 commits `v2/main` lacks), so a back-merge grafts all of it into the develop branch permanently to deliver a two-file change. Full procedure in the root README's [Cutting a release](./README.md#cutting-a-release). + ⚠️ **The version bump rides that same flow — it is made on `v2/main`, never on the milestone-merge branch (#2010).** `npm version --no-git-tag-version` lands on `v2/main` *before* the milestone merge, so the bump reaches `main` as part of the milestone's work; the release tag (bare `x.y.z`, no `v` prefix) is then applied to the resulting **`main`** commit. This deliberately leaves a normal in-flight interval where `v2/main` reads the version being built and `main` the one currently released — that difference is expected. The invariant it buys is that they converge when the release merge lands, so `v2/main` is never left *behind* `main` afterwards. Bumping on the milestone-merge branch instead (which is cut from `main`) puts the bump only downstream of `v2/main`, where nothing carries it back — that is how `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases, and how #2009 came to carry an unrelated `2.0.0 → 2.2.0` diff simply because its branch was cut from a milestone-merge branch. **Never close a drift by merging `main` into `v2/main`**: `main` holds the whole pre-v2 v1 history (~230 commits `v2/main` lacks), so a back-merge grafts all of it into the develop branch permanently to deliver a two-file change. Full procedure in the root README's [Cutting a release](./README.md#cutting-a-release). A corollary for **branching**: cut feature branches from **`v2/main`**, never from a milestone-merge branch — the latter carries release-only commits that will show up in your PR's diff. diff --git a/README.md b/README.md index f90ed822..c845c540 100644 --- a/README.md +++ b/README.md @@ -330,21 +330,25 @@ Because there is **one version number** (only the root `package.json` has one **1. Bump on `v2/main`, before the milestone merge.** The bump is part of the milestone's work, so it belongs on the develop branch and flows into `main` with everything else: +Substitute the real issue number and release below — the commands are written to be copy-pasteable as-is (a `2.2.0` → `2.3.0` minor bump): + ```bash -git checkout -b v2/chore/-bump-x-y-z v2/main -npm version --no-git-tag-version # bump only, no tag +git checkout -b v2/chore/2010-bump-2-3-0 v2/main +npm version minor --no-git-tag-version # or major / patch; bump only, no tag # PR → v2/main ``` ⚠️ **`--no-git-tag-version` is load-bearing.** A bare `npm version` also tags, and the tag would land on a `v2/main` commit — but the release must be cut from `main`, so the tag has to point at the merge commit there (step 3). Tagging here creates a tag on a commit that is never released. -**2. Merge `v2/main` → `main`** through the usual milestone-merge branch. It now carries the bump, so `main` and `v2/main` agree on the version at every point. +**2. Merge `v2/main` → `main`** through the usual milestone-merge branch. It now carries the bump, so the release lands on `main` with the version already correct. + +Between steps 1 and 2 the two branches **do** differ, and that is expected, not drift: `v2/main` reads the version being built while `main` still reads the one currently released. What this ordering removes is *post-release* drift — once the milestone merge lands they agree again, and `v2/main` is never left **behind** `main`. If you see `v2/main` ahead of `main`, a release is in flight; if you see it behind, something went wrong. **3. Tag the `main` commit and draft the Release:** ```bash git checkout main && git pull -git tag && git push origin +git tag 2.3.0 && git push origin 2.3.0 # then draft & publish a GitHub Release for that tag → triggers `publish` ``` From d0143ef8437fc82b9755c268cd16a72b492dbfc8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 15:04:34 -0400 Subject: [PATCH 11/58] docs: tag origin/main explicitly so a release can't point at a local commit git checkout main && git pull resolves through the maintainer's configured merge/rebase strategy, so a divergent local main can produce or replay local commits. Tagging HEAD there tags a commit that is not on origin/main, and pushing only the tag leaves a release whose commit was never published. git tag origin/main pins the tag to what the remote branch actually points at, regardless of local state. Signed-off-by: cliffhall --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c845c540..a15210d1 100644 --- a/README.md +++ b/README.md @@ -347,11 +347,13 @@ Between steps 1 and 2 the two branches **do** differ, and that is expected, not **3. Tag the `main` commit and draft the Release:** ```bash -git checkout main && git pull -git tag 2.3.0 && git push origin 2.3.0 +git fetch origin main +git tag 2.3.0 origin/main && git push origin 2.3.0 # then draft & publish a GitHub Release for that tag → triggers `publish` ``` +⚠️ **Tag `origin/main`, not your local `HEAD`.** `git checkout main && git pull` resolves through whatever merge-or-rebase strategy you have configured, so a divergent local `main` can quietly produce or replay local commits. Tagging `HEAD` there tags a commit that is not on `origin/main`, and `git push origin ` pushes only the tag — leaving a release whose commit was never published. Naming `origin/main` explicitly makes the tagged commit exactly what the remote branch points at, regardless of local state. + ⚠️ **No `v` prefix.** This repo's release tags are bare `x.y.z` — `2.2.0`, `2.1.0`, `2.0.0` — so tag `2.3.0`, not `v2.3.0`. Note npm's own `tag-version-prefix` defaults to `v` and the repo sets no `.npmrc`, so a bare `npm version` would have produced a `v`-prefixed tag that does not match the convention. Tagging by hand (step 3) is what keeps it right. The workflow's assert step strips a leading `v` before comparing, so a `v`-prefixed tag would still publish — it would just be inconsistent with every previous release. The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow. From 937f1dd51b7310798265f3b303305ca3fe134a67 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 16:10:17 -0400 Subject: [PATCH 12/58] fix: render nullable enum fields as a Select instead of a raw JSON textarea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool argument that is optional AND explicitly nullable — Zod's `.nullish()` / `.nullable().optional()` — compiles to `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`. Every branch of the tool-call form's field dispatcher tests a single `type` string, so such a field matched nothing and fell through to the raw-JSON fallback, whose onChange stores the unparseable in-progress text back into state and re-`JSON.stringify`s it on the next render — compounding a fresh layer of escaping on every keystroke. `normalizeUnionType` already existed for the FastMCP `string|null` shape, but it only copied the surviving branch's `type` and dropped everything else, so the `enum` never reached the dispatcher; and nothing called it on a tool input schema — only elicitation requests, via `resolveRefsInMessage`. So: - Rewrite `normalizeUnionType` to hoist the non-null branch's own keywords (`enum`, `items`, `properties`, `minimum`, …) onto the flattened schema, as v1.x did. This also collapses nine near-identical blocks into two and picks up the `object`/`array` cases the `type: [X, "null"]` form was missing. - Normalize each field in `SchemaForm.renderField`, so tool, elicitation, and app forms all get it rather than just elicitation. - Give a nullable enum `Select` a clear affordance, so a value can be set back to `null`; a non-nullable enum keeps none, since it would reject it. Adds a `record_shipment` preset and `nullable-fields-http.json` showcase config serving all four nullable scalar shapes, for manual verification. Signed-off-by: cliffhall --- README.md | 7 + .../groups/SchemaForm/SchemaForm.test.tsx | 176 ++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 21 ++- clients/web/src/utils/schemaUtils.test.ts | 114 ++++++++++++ clients/web/src/utils/schemaUtils.ts | 146 +++++++-------- .../configs/nullable-fields-http.json | 11 ++ test-servers/src/preset-registry.ts | 4 + test-servers/src/test-server-fixtures.ts | 33 ++++ 8 files changed, 428 insertions(+), 84 deletions(-) create mode 100644 test-servers/configs/nullable-fields-http.json diff --git a/README.md b/README.md index a15210d1..82164a98 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) | | `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) | | `duplicate-tool-names-http.json` | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) | +| `nullable-fields-http.json` | Tools tab: nullable (`anyOf` + `null`) arguments | [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) | | `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | | `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | | `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | @@ -227,6 +228,12 @@ Connect (default legacy era), open the Tools tab, and type `get` into **Search t The duplicated copies are appended rather than placed beside their twin on purpose. React matches a leading run of same-key children first, so a head-adjacent duplicate happens to line up and the defect hides; separating the pair is what makes it observable — and it is also the realistic shape, two tool sources concatenated. +#### Nullable arguments + +`nullable-fields-http.json` serves `record_shipment`, whose four arguments are each declared with Zod's `.nullish()` — "optional **and** explicitly nullable". That compiles to `anyOf: [, { "type": "null" }]`, so the real type (and, for the enum, its `enum` list) sits on a branch rather than at the top level. `get_temp` sits alongside it with a plain, non-nullable `units` enum for comparison. Plain streamable-HTTP — connect with the **default (legacy)** protocol era. + +Open the Tools tab and select `record_shipment`: `direction` must render as a **Select** (`envio` / `recebimento`) with a clear button that sets it back to `null`, `reference` as a text input, `quantity` as a number input, and `express` as a checkbox. On the broken build every one of them fell through to the raw-JSON textarea, which re-escaped its own contents on each keystroke until the value was unusable ([#1928](https://github.com/modelcontextprotocol/inspector/issues/1928)). The tool echoes the arguments it received, so the result panel shows exactly what was sent. + #### Advertised extensions `advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`. diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 5b9f064a..3ff41b50 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -811,3 +811,179 @@ describe("SchemaForm", () => { expect(container.firstChild).not.toBeNull(); }); }); + +// #1928: "optional AND explicitly nullable" (Zod's `.nullish()`) compiles to a +// nullable union rather than a plain type. Before the normalization step these +// matched no branch and fell through to the raw-JSON fallback, where every +// keystroke re-escaped the value. +describe("SchemaForm nullable unions", () => { + it("renders a Select for an anyOf string-enum|null field", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + type: { + title: "Type", + anyOf: [ + { type: "string", enum: ["envio", "recebimento"] }, + { type: "null" }, + ], + }, + }, + }; + renderWithMantine( + , + ); + await user.click(screen.getByRole("textbox", { name: "Type" })); + await user.click( + await screen.findByRole("option", { name: "envio", hidden: true }), + ); + expect(onChange).toHaveBeenCalledWith({ type: "envio" }); + }); + + it("clears a nullable enum back to null", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + type: { + title: "Type", + anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }], + }, + }, + }; + renderWithMantine( + , + ); + // Mantine marks its combobox clear button `aria-hidden` (it is mouse-only, + // `tabIndex={-1}`), so it is only reachable with `hidden: true`. + await user.click(screen.getByRole("button", { hidden: true })); + expect(onChange).toHaveBeenCalledWith({ type: null }); + }); + + it("offers no clear affordance on a non-nullable enum", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + format: { type: "string", title: "Format", enum: ["json"] }, + }, + }; + renderWithMantine( + , + ); + expect( + screen.queryByRole("button", { hidden: true }), + ).not.toBeInTheDocument(); + }); + + it("renders a TextInput for a type: [string, null] field", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + note: { type: ["string", "null"], title: "Note" }, + }, + }; + renderWithMantine( + , + ); + await user.type(screen.getByLabelText(/Note/), "a"); + expect(onChange).toHaveBeenCalledWith({ note: "a" }); + }); + + it("renders a checkbox for an anyOf boolean|null field", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + enabled: { + title: "Enabled", + anyOf: [{ type: "boolean" }, { type: "null" }], + }, + }, + }; + renderWithMantine( + , + ); + await user.click(screen.getByLabelText("Enabled")); + expect(onChange).toHaveBeenCalledWith({ enabled: true }); + }); + + it("renders a MultiSelect for an anyOf array-of-enum|null field", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + tags: { + title: "Tags", + anyOf: [ + { type: "array", items: { type: "string", enum: ["a", "b"] } }, + { type: "null" }, + ], + }, + }, + }; + renderWithMantine( + , + ); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.click( + await screen.findByRole("option", { name: "a", hidden: true }), + ); + expect(onChange).toHaveBeenCalledWith({ tags: ["a"] }); + }); + + it("renders nested fields for an anyOf object|null field", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + profile: { + title: "Profile", + anyOf: [ + { + type: "object", + properties: { nick: { type: "string", title: "Nick" } }, + }, + { type: "null" }, + ], + }, + }, + }; + renderWithMantine( + , + ); + await user.type(screen.getByLabelText(/Nick/), "z"); + expect(onChange).toHaveBeenCalledWith({ profile: { nick: "z" } }); + }); + + it("still falls back to the JSON input for a union of two real types", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + mixed: { + title: "Mixed", + anyOf: [{ type: "string" }, { type: "number" }], + }, + }, + }; + renderWithMantine( + , + ); + expect(screen.getByLabelText(/Mixed/).tagName).toBe("TEXTAREA"); + }); +}); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 232b5327..eadb0564 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -12,6 +12,7 @@ import { useState } from "react"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import { useValueChange } from "../../../hooks/useValueChange"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; +import { normalizeUnionType } from "../../../utils/schemaUtils"; const FieldLabel = Text.withProps({ fw: 500, @@ -51,6 +52,16 @@ function toEnumData( return values; } +/** + * Whether an enum `Select` should offer a clear affordance. Only a schema that + * admits `null` does: clearing emits `null`, and an enum without a null branch + * would reject it. Without this a nullable enum is a one-way door — once a + * value is picked there is no way back to "no answer". + */ +function isClearable(fieldSchema: InspectorFormSchema): boolean { + return fieldSchema.nullable === true; +} + /** * Interpret whatever Mantine's `NumberInput` reported as the JSON value for the * field. Anything that is not a finite number becomes `undefined`, which is how @@ -207,7 +218,13 @@ export function SchemaForm({ onChange({ ...values, [fieldName]: fieldValue }); } - function renderField(fieldName: string, fieldSchema: InspectorFormSchema) { + function renderField(fieldName: string, rawSchema: InspectorFormSchema) { + // Flatten a nullable union (`anyOf: [X, {type:"null"}]`, `type: [X,"null"]`) + // before dispatching. Every branch below tests a single `type` string, so + // without this an "optional and explicitly nullable" field — what Zod's + // `.nullish()` emits — matches nothing and falls through to the raw-JSON + // fallback, which is unusable for a value the user has to type (#1928). + const fieldSchema = normalizeUnionType(rawSchema); const isRequired = requiredFields.includes(fieldName); const label = fieldSchema.title ?? fieldName; const description = fieldSchema.description; @@ -223,6 +240,7 @@ export function SchemaForm({ withAsterisk={isRequired} disabled={disabled} data={toEnumData(fieldSchema.enum, fieldSchema.enumNames)} + clearable={isClearable(fieldSchema)} value={(rawValue as string) ?? null} onChange={(val) => handleFieldChange(fieldName, val)} /> @@ -243,6 +261,7 @@ export function SchemaForm({ withAsterisk={isRequired} disabled={disabled} data={data} + clearable={isClearable(fieldSchema)} value={(rawValue as string) ?? null} onChange={(val) => handleFieldChange(fieldName, val)} /> diff --git a/clients/web/src/utils/schemaUtils.test.ts b/clients/web/src/utils/schemaUtils.test.ts index 2689f317..59623fdb 100644 --- a/clients/web/src/utils/schemaUtils.test.ts +++ b/clients/web/src/utils/schemaUtils.test.ts @@ -347,6 +347,120 @@ describe("normalizeUnionType", () => { }; expect(normalizeUnionType(schema)).toBe(schema); }); + + it("ignores a two-member anyOf with no null branch", () => { + const schema = { + anyOf: [{ type: "string" as const }, { type: "number" as const }], + }; + expect(normalizeUnionType(schema)).toBe(schema); + }); + + it("ignores an anyOf whose non-null branch has no renderable type (#1928)", () => { + const schema = { + anyOf: [{ const: "only" }, { type: "null" as const }], + }; + expect(normalizeUnionType(schema)).toBe(schema); + }); + + // #1928: the enum lives on the surviving branch, and hoisting it is what makes + // the field render as a Select instead of the raw-JSON fallback. + it("hoists enum out of an anyOf string-enum|null branch", () => { + expect( + normalizeUnionType({ + description: "Direction", + anyOf: [ + { type: "string", enum: ["envio", "recebimento"] }, + { type: "null" }, + ], + }), + ).toEqual({ + type: "string", + description: "Direction", + enum: ["envio", "recebimento"], + anyOf: undefined, + nullable: true, + }); + }); + + it("infers string for a typeless enum branch", () => { + expect( + normalizeUnionType({ + anyOf: [{ enum: ["a", "b"] }, { type: "null" }], + }), + ).toEqual({ + type: "string", + enum: ["a", "b"], + anyOf: undefined, + nullable: true, + }); + }); + + it("hoists items out of an anyOf array|null branch", () => { + expect( + normalizeUnionType({ + anyOf: [ + { type: "array", items: { type: "string", enum: ["a", "b"] } }, + { type: "null" }, + ], + }), + ).toEqual({ + type: "array", + items: { type: "string", enum: ["a", "b"] }, + anyOf: undefined, + nullable: true, + }); + }); + + it("hoists properties out of an anyOf object|null branch", () => { + expect( + normalizeUnionType({ + anyOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "null" }, + ], + }), + ).toEqual({ + type: "object", + properties: { a: { type: "string" } }, + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes anyOf object|null regardless of branch order", () => { + expect( + normalizeUnionType({ + anyOf: [{ type: "null" }, { type: "object" }], + }), + ).toEqual({ type: "object", anyOf: undefined, nullable: true }); + }); + + it("normalizes type array object|null", () => { + expect( + normalizeUnionType({ + type: ["object", "null"], + properties: { a: { type: "string" } }, + }), + ).toEqual({ + type: "object", + properties: { a: { type: "string" } }, + nullable: true, + }); + }); + + it("normalizes type array array|null", () => { + expect(normalizeUnionType({ type: ["array", "null"] })).toEqual({ + type: "array", + nullable: true, + }); + }); + + it("keeps a type array of two non-null members unchanged", () => { + const schema = { type: ["string", "number"] as const }; + expect(normalizeUnionType({ ...schema, type: [...schema.type] })).toEqual({ + type: ["string", "number"], + }); + }); }); describe("formatFieldLabel", () => { diff --git a/clients/web/src/utils/schemaUtils.ts b/clients/web/src/utils/schemaUtils.ts index 4e578a1c..2624fba1 100644 --- a/clients/web/src/utils/schemaUtils.ts +++ b/clients/web/src/utils/schemaUtils.ts @@ -201,101 +201,81 @@ export function resolveRef( } /** - * Normalizes union types (like string|null from FastMCP) to simple types for form rendering + * The `type` values {@link SchemaForm} dispatches on. `"null"` is deliberately + * absent: it is the branch a nullable union is being flattened *away* from, and + * there is no widget for a field whose only permitted value is `null`. + */ +const RENDERABLE_TYPES = [ + "string", + "number", + "integer", + "boolean", + "array", + "object", +] as const; + +type RenderableType = (typeof RENDERABLE_TYPES)[number]; + +function isRenderableType(type: unknown): type is RenderableType { + return RENDERABLE_TYPES.includes(type as RenderableType); +} + +/** + * Normalizes a nullable union (`string|null` from FastMCP, or anything Zod's + * `.nullish()` / `.nullable()` compiles to) into the plain type the form + * renderer dispatches on. + * + * Two encodings mean the same thing and are both flattened to + * `{ type: , nullable: true }`: + * + * - `anyOf: [, { type: "null" }]` — the branch's *own* keywords are + * hoisted onto the result, because that is where the detail the renderer + * needs lives. A nullable enum compiles to + * `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`, so hoisting + * `enum` is what makes it a `Select` instead of falling through to the raw + * JSON fallback (#1928). The branch also wins over the wrapper on any shared + * key, matching v1.x's behavior. + * - `type: [, "null"]` — the keywords already sit at the top level, so only + * `type` collapses. + * + * Anything else — a union of two real types, a three-member `anyOf`, a branch + * whose type has no widget — is returned untouched and renders through the + * JSON fallback, which is the honest representation of a shape the form cannot + * model. + * * @param schema The JSON schema to normalize - * @returns A normalized schema or the original schema + * @returns A normalized schema, or the original schema when no nullable-union + * pattern matches */ export function normalizeUnionType( schema: InspectorFormSchema, ): InspectorFormSchema { - // Handle anyOf with exactly string and null (FastMCP pattern) - if ( - schema.anyOf && - schema.anyOf.length === 2 && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "string") && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null") - ) { - return { ...schema, type: "string", anyOf: undefined, nullable: true }; + if (schema.anyOf && schema.anyOf.length === 2) { + // `JsonSchemaConst` is structurally a subset of `InspectorFormSchema` (it + // just carries no `type`), so reading both as the latter is safe. + const branches = schema.anyOf as InspectorFormSchema[]; + const nullBranch = branches.find((branch) => branch?.type === "null"); + const branch = branches.find((candidate) => candidate?.type !== "null"); + + if (nullBranch && branch) { + // A bare `{ enum: [...] }` branch carries no `type`; JSON Schema allows + // that, and every value such a schema admits here is a string. + const type = branch.type ?? (branch.enum ? "string" : undefined); + if (isRenderableType(type)) { + return { ...schema, ...branch, type, anyOf: undefined, nullable: true }; + } + } } - // Handle anyOf with exactly boolean and null (FastMCP pattern) - if ( - schema.anyOf && - schema.anyOf.length === 2 && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "boolean") && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null") - ) { - return { ...schema, type: "boolean", anyOf: undefined, nullable: true }; - } - - // Handle anyOf with exactly number and null (FastMCP pattern) - if ( - schema.anyOf && - schema.anyOf.length === 2 && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "number") && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null") - ) { - return { ...schema, type: "number", anyOf: undefined, nullable: true }; - } - - // Handle anyOf with exactly integer and null (FastMCP pattern) - if ( - schema.anyOf && - schema.anyOf.length === 2 && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "integer") && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null") - ) { - return { ...schema, type: "integer", anyOf: undefined, nullable: true }; - } - - // Handle anyOf with exactly array and null (FastMCP pattern) - if ( - schema.anyOf && - schema.anyOf.length === 2 && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "array") && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null") - ) { - return { ...schema, type: "array", anyOf: undefined, nullable: true }; - } - - // Handle array type with exactly string and null if ( Array.isArray(schema.type) && schema.type.length === 2 && - schema.type.includes("string") && schema.type.includes("null") ) { - return { ...schema, type: "string", nullable: true }; - } - - // Handle array type with exactly boolean and null - if ( - Array.isArray(schema.type) && - schema.type.length === 2 && - schema.type.includes("boolean") && - schema.type.includes("null") - ) { - return { ...schema, type: "boolean", nullable: true }; - } - - // Handle array type with exactly number and null - if ( - Array.isArray(schema.type) && - schema.type.length === 2 && - schema.type.includes("number") && - schema.type.includes("null") - ) { - return { ...schema, type: "number", nullable: true }; - } - - // Handle array type with exactly integer and null - if ( - Array.isArray(schema.type) && - schema.type.length === 2 && - schema.type.includes("integer") && - schema.type.includes("null") - ) { - return { ...schema, type: "integer", nullable: true }; + const type = schema.type.find((member) => member !== "null"); + if (isRenderableType(type)) { + return { ...schema, type, nullable: true }; + } } return schema; diff --git a/test-servers/configs/nullable-fields-http.json b/test-servers/configs/nullable-fields-http.json new file mode 100644 index 00000000..91c4b647 --- /dev/null +++ b/test-servers/configs/nullable-fields-http.json @@ -0,0 +1,11 @@ +{ + "serverInfo": { + "name": "nullable-fields-showcase", + "version": "1.0.0" + }, + "tools": [{ "preset": "record_shipment" }, { "preset": "get_temp" }], + "transport": { + "type": "streamable-http", + "port": 6602 + } +} diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index 806769b3..d78998ec 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -12,6 +12,7 @@ import type { } from "./composable-test-server.js"; import { createEchoTool, + createNullableFieldsTool, createGetEnvTool, createAddTool, createGetSumTool, @@ -91,6 +92,9 @@ function resolveToolPreset( switch (name) { case "echo": return createEchoTool(); + case "record_shipment": + case "nullable_fields": + return createNullableFieldsTool(); case "get-env": case "get_env": return createGetEnvTool(); diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 5a05b07b..070b5750 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -162,6 +162,39 @@ export function createEchoTool(): ToolDefinition { }; } +/** + * Create a "record_shipment" tool whose arguments are all **nullable** — the + * shape Zod's `.nullish()` / `.nullable()` compiles to, where the real type + * sits inside `anyOf: [, { type: "null" }]` rather than at the top + * level. + * + * `direction` is the case from #1928: a nullable *enum*, whose `enum` keyword + * lives on the surviving branch. Before the fix nothing in the tool-call form + * matched such a field, so it fell through to the raw-JSON textarea and + * re-escaped its own value on every keystroke. The other three cover the + * remaining scalar branches so a regression in one is visible next to a + * working sibling. + */ +export function createNullableFieldsTool(): ToolDefinition { + return { + name: "record_shipment", + description: + "Record a shipment. Every argument is optional AND explicitly nullable (Zod .nullish()), so each compiles to an anyOf union with a null branch.", + inputSchema: { + direction: z + .enum(["envio", "recebimento"]) + .nullish() + .describe("Direction of the shipment (nullable enum — #1928)"), + reference: z.string().nullish().describe("Free-text reference"), + quantity: z.number().int().nullish().describe("Number of packages"), + express: z.boolean().nullish().describe("Ship express"), + }, + handler: async (params: Record) => { + return toToolResult(JSON.stringify(params, null, 2)); + }, + }; +} + /** * Create a "get-env" tool matching @modelcontextprotocol/server-everything. * Returns the server process environment as pretty-printed JSON text. From fe5b622fd143b37f94535eafaa19ab2f8972a6f8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 16:52:11 -0400 Subject: [PATCH 13/58] fix: salvage a list result's valid entries when one is malformed (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK validates a list result as a whole, so a single non-conforming entry rejected the entire response and the panel showed "Couldn't load resources" with zero items — the exact opposite of what a debugging tool should do with a server that is 90% right. Reported against a PHP server whose empty `annotations` object reaches the wire as `[]` rather than `{}`. `listAll*` now falls back to a lenient re-walk when — and only when — the SDK rejects a result it received (`InvalidResult` / `UnsupportedResultType`), keeps the entries that validate, and reports the rest. A conforming server takes the strict, cache-aware path exactly as before. Salvaging is not accepting: the dropped entries are named with their reason in a warning above the list, and the Protocol entry is still marked rejected, so the wire truth survives. When every entry validates, the original error is rethrown rather than swallowed — the rejection was about something the per-item pass cannot explain. `isClientDecodeRejection` moves to the new module and is now shared with managedListState, which had the only copy. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- AGENTS.md | 6 +- clients/web/src/App.test.tsx | 1 + clients/web/src/App.tsx | 2 + .../MalformedItemsWarning.stories.tsx | 89 +++++ .../MalformedItemsWarning.test.tsx | 97 ++++++ .../MalformedItemsWarning.tsx | 99 ++++++ .../groups/PromptControls/PromptControls.tsx | 14 + .../ResourceControls/ResourceControls.tsx | 19 ++ .../groups/ToolControls/ToolControls.tsx | 14 + .../screens/PromptsScreen/PromptsScreen.tsx | 8 + .../ResourcesScreen/ResourcesScreen.tsx | 8 + .../screens/ToolsScreen/ToolsScreen.tsx | 8 + .../views/InspectorView/InspectorView.tsx | 10 + .../web/src/test/core/mcp/listSalvage.test.ts | 232 +++++++++++++ .../inspectorClient-malformed-list.test.ts | 320 ++++++++++++++++++ core/mcp/__tests__/fakeInspectorClient.ts | 13 + core/mcp/index.ts | 3 + core/mcp/inspectorClient.ts | 253 ++++++++++++-- core/mcp/inspectorClientEventTarget.ts | 7 + core/mcp/inspectorClientProtocol.ts | 4 + core/mcp/listSalvage.ts | 199 +++++++++++ core/mcp/state/managedListState.ts | 34 +- core/react/useInspectorClient.ts | 26 ++ 23 files changed, 1402 insertions(+), 64 deletions(-) create mode 100644 clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.stories.tsx create mode 100644 clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.test.tsx create mode 100644 clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.tsx create mode 100644 clients/web/src/test/core/mcp/listSalvage.test.ts create mode 100644 clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts create mode 100644 core/mcp/listSalvage.ts diff --git a/AGENTS.md b/AGENTS.md index 0baea459..a96c06da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,11 @@ v2/main/ │ ├── mcp/ # InspectorClient runtime + state stores │ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks │ │ # extension wire schemas + normalize/handle helpers, -│ │ # used by the raw-wire tasks/* channel — #1631) +│ │ # used by the raw-wire tasks/* channel — #1631; +│ │ # listSalvage.ts: per-item salvage for list results — +│ │ # keeps the valid entries when one is non-conforming +│ │ # instead of losing the whole list, and owns the +│ │ # shared isClientDecodeRejection predicate — #1909) │ │ ├── import/ # Config import strategies (#1348): client-config parsers │ │ │ # (Claude Desktop/Cursor/Cline/VS Code), registry │ │ │ # server.json parser, strategy registry + well-known diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 0869d0d5..8ca842ed 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -598,6 +598,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType = { serverInfo: undefined, instructions: undefined, excludedTools: [], + malformedListItems: [], appRendererClient: null, connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined), diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 509fc079..b42a2e3b 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -990,6 +990,7 @@ function App() { protocolEra, discoverResult, excludedTools, + malformedListItems, lastError, } = useInspectorClient(inspectorClient); const { @@ -4376,6 +4377,7 @@ function App() { latencyMs={latencyMs} tools={tools} excludedTools={excludedTools} + malformedListItems={malformedListItems} prompts={prompts} resources={resources} resourceTemplates={resourceTemplates} diff --git a/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.stories.tsx b/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.stories.tsx new file mode 100644 index 00000000..756e8667 --- /dev/null +++ b/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { MalformedItemsWarning } from "./MalformedItemsWarning"; + +const meta: Meta = { + title: "Elements/MalformedItemsWarning", + component: MalformedItemsWarning, +}; + +export default meta; +type Story = StoryObj; + +/** The failure this was built for: a PHP server whose empty `annotations` + * object reaches the wire as `[]` (#1909). */ +export const OneDroppedTemplate: Story = { + args: { + method: "resources/templates/list", + what: "resource templates", + items: [ + { + method: "resources/templates/list", + index: 1, + label: "array_annotations", + reason: "annotations: Invalid input: expected object, received array", + }, + ], + }, +}; + +export const SeveralDropped: Story = { + args: { + method: "tools/list", + what: "tools", + items: [ + { + method: "tools/list", + index: 0, + label: "get_weather", + reason: "inputSchema: Invalid input: expected object, received string", + }, + { + method: "tools/list", + index: 4, + label: "add", + reason: "name: Invalid input: expected string, received number", + }, + ], + }, +}; + +/** An entry too broken to carry a name is reported by its position. */ +export const UnlabeledEntry: Story = { + args: { + method: "prompts/list", + what: "prompts", + items: [ + { + method: "prompts/list", + index: 2, + reason: "Invalid input: expected object, received null", + }, + ], + }, +}; + +/** Entries belonging to another list method are ignored — the same set is + * passed to every panel, and each selects its own. */ +export const OtherMethodOnly: Story = { + args: { + method: "resources/templates/list", + what: "resource templates", + items: [ + { + method: "tools/list", + index: 0, + label: "get_weather", + reason: "inputSchema: Invalid input: expected object, received string", + }, + ], + }, +}; + +/** The resting state: a conforming server, nothing rendered. */ +export const Conforming: Story = { + args: { + method: "resources/templates/list", + what: "resource templates", + items: [], + }, +}; diff --git a/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.test.tsx b/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.test.tsx new file mode 100644 index 00000000..ed8c03f0 --- /dev/null +++ b/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import type { MalformedListItem } from "@inspector/core/mcp"; +import { renderWithMantine } from "../../../test/renderWithMantine"; +import { MalformedItemsWarning } from "./MalformedItemsWarning"; + +const TEMPLATE_ENTRY: MalformedListItem = { + method: "resources/templates/list", + index: 1, + label: "array_annotations", + reason: "annotations: Invalid input: expected object, received array", +}; + +const TOOL_ENTRY: MalformedListItem = { + method: "tools/list", + index: 0, + label: "get_weather", + reason: "inputSchema: Invalid input: expected object, received string", +}; + +describe("MalformedItemsWarning", () => { + it("names the dropped entry and why it was dropped", () => { + renderWithMantine( + , + ); + + expect(screen.getByText("1 malformed entry dropped")).toBeInTheDocument(); + expect(screen.getByText("array_annotations")).toBeInTheDocument(); + expect(screen.getByText(TEMPLATE_ENTRY.reason)).toBeInTheDocument(); + }); + + it("renders nothing when the server was conforming", () => { + renderWithMantine( + , + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("ignores entries belonging to another list method", () => { + // Every panel receives the whole set and selects its own — a tools failure + // must not surface above the templates list. + renderWithMantine( + , + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByText("get_weather")).not.toBeInTheDocument(); + }); + + it("pluralizes the count and lists every entry", () => { + renderWithMantine( + , + ); + + expect(screen.getByText("2 malformed entries dropped")).toBeInTheDocument(); + expect(screen.getByText("get_weather")).toBeInTheDocument(); + expect(screen.getByText("add")).toBeInTheDocument(); + // The template entry belongs to the other panel. + expect(screen.queryByText("array_annotations")).not.toBeInTheDocument(); + }); + + it("falls back to the position when the entry had no usable name", () => { + renderWithMantine( + , + ); + expect(screen.getByText("entry 2")).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.tsx b/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.tsx new file mode 100644 index 00000000..26bc5d5c --- /dev/null +++ b/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.tsx @@ -0,0 +1,99 @@ +import { Alert, Code, List, ScrollArea, Text } from "@mantine/core"; +import type { MalformedListItem } from "@inspector/core/mcp"; + +export interface MalformedItemsWarningProps { + /** + * Every malformed entry the client is currently reporting, across all list + * methods — pass the hook's set straight through; the component selects its + * own by {@link method}. + */ + items: MalformedListItem[]; + /** The list method this panel renders, e.g. `"resources/templates/list"`. */ + method: string; + /** What the entries are, for the alert title — e.g. "resource templates". */ + what: string; +} + +// Yellow, not red: unlike ListLoadError this is not a failure — the list DID +// load and is rendered below. It reports what the server sent that the +// Inspector could not accept. +const WarningAlert = Alert.withProps({ + color: "yellow", + variant: "light", +}); + +// The entry list stays compact; the reasons are the payload, so they get the +// monospace treatment while the labels stay in body type. +const EntryList = List.withProps({ + size: "xs", + spacing: 4, + withPadding: true, +}); + +const EntryReason = Code.withProps({ + variant: "wrapping", +}); + +// A server can be wrong about many entries at once; cap the height so a badly +// broken list can't push the list itself off the sidebar. +const EntryScroll = ScrollArea.withProps({ + mah: 180, + type: "auto", +}); + +const SummaryText = Text.withProps({ + size: "xs", + mb: 4, +}); + +// The entry's identity, emphasized against its monospaced reason. +const EntryLabel = Text.withProps({ + component: "span", + size: "xs", + fw: 600, +}); + +/** `label` when the entry had one, else its position in the response. */ +function describeEntry(item: MalformedListItem): string { + return item.label ?? `entry ${item.index}`; +} + +/** + * "Some entries were dropped" — the visible half of the #1909 salvage. + * + * The client keeps the valid entries of a list whose result failed schema + * validation, rather than losing the whole list to one bad entry. Dropping them + * silently would trade one lie for another, so what was dropped, and why, is + * reported here beside the list that rendered without them. + */ +export function MalformedItemsWarning({ + items, + method, + what, +}: MalformedItemsWarningProps) { + const mine = items.filter((item) => item.method === method); + if (mine.length === 0) return null; + + return ( + + + The server returned {what} that don't match the MCP schema. The + rest of the list is shown below. + + + + {mine.map((item) => ( + + {describeEntry(item)}{" "} + {item.reason} + + ))} + + + + ); +} diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.tsx index 2a2d5364..2fbb5633 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.tsx @@ -3,6 +3,8 @@ import { ClearButton } from "../../elements/ClearButton/ClearButton"; import type { Prompt } from "@modelcontextprotocol/client"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; +import { MalformedItemsWarning } from "../../elements/MalformedItemsWarning/MalformedItemsWarning"; +import type { MalformedListItem } from "@inspector/core/mcp"; import { ListPaginationControls, type ListPaginationControlsProps, @@ -41,6 +43,12 @@ export interface PromptControlsProps { * A failed list load, surfaced above the list instead of leaving the panel * empty (which reads as "this server has none") (#1953). */ + /** + * Entries the client dropped from this list because they failed the MCP + * schema. Rendered as a warning above the list, which still shows the rest + * (#1909). + */ + malformedListItems?: MalformedListItem[]; loadError?: Error | null; /** Pagination controls (#1721). */ pagination: ListPaginationControlsProps; @@ -54,6 +62,7 @@ export function PromptControls({ searchText = "", listChanged, onRefreshList, + malformedListItems = [], loadError, pagination, onSearchChange, @@ -83,6 +92,11 @@ export function PromptControls({ /> + {filteredPrompts.map((prompt) => ( diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx index adf5aa1a..6a319fb1 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -14,6 +14,8 @@ import { isModernEra } from "../../elements/EraBadge/eraUtils"; import { SubscriptionStreamBadge } from "../../elements/SubscriptionStreamBadge/SubscriptionStreamBadge"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; +import { MalformedItemsWarning } from "../../elements/MalformedItemsWarning/MalformedItemsWarning"; +import type { MalformedListItem } from "@inspector/core/mcp"; import { ListPaginationControls, type ListPaginationControlsProps, @@ -73,6 +75,12 @@ export interface ResourceControlsProps { * A failed list load, surfaced above the list instead of leaving the panel * empty (which reads as "this server has none") (#1953). */ + /** + * Entries the client dropped from `resources/list` or + * `resources/templates/list` because they failed the MCP schema. Rendered as + * a warning above the list, which still shows the rest (#1909). + */ + malformedListItems?: MalformedListItem[]; loadError?: Error | null; /** Pagination controls for the Resources list (#1721). */ pagination: ListPaginationControlsProps; @@ -117,6 +125,7 @@ export function ResourceControls({ openSections: controlledOpenSections, listChanged, onRefreshList, + malformedListItems = [], loadError, pagination, onSearchChange, @@ -248,6 +257,16 @@ export function ResourceControls({ what="resources" onRetry={onRefreshList} /> + + {/* Stays inline: Accordion is a compound, `multiple`-discriminated generic, so `.withProps({ multiple: true, ... })` loses its JSX call signature (same tooling limit as Box). */} diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.tsx index 4ad70d85..01ad1f04 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -15,6 +15,8 @@ import type { Tool } from "@modelcontextprotocol/client"; import type { ExcludedTool } from "@inspector/core/mcp/types.js"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; +import { MalformedItemsWarning } from "../../elements/MalformedItemsWarning/MalformedItemsWarning"; +import type { MalformedListItem } from "@inspector/core/mcp"; import { ListPaginationControls, type ListPaginationControlsProps, @@ -37,6 +39,12 @@ export interface ToolControlsProps { * A failed list load, surfaced above the list instead of leaving the panel * empty (which reads as "this server has none") (#1953). */ + /** + * Entries the client dropped from this list because they failed the MCP + * schema. Rendered as a warning above the list, which still shows the rest + * (#1909). + */ + malformedListItems?: MalformedListItem[]; loadError?: Error | null; /** Pagination controls (#1721). */ pagination: ListPaginationControlsProps; @@ -127,6 +135,7 @@ export function ToolControls({ searchText = "", listChanged, onRefreshList, + malformedListItems = [], loadError, pagination, onSearchChange, @@ -163,6 +172,11 @@ export function ToolControls({ /> + {filteredTools.map(({ tool, key }) => ( diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx index c599ed47..8b491cc8 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx @@ -8,6 +8,7 @@ import { Stack, Text, } from "@mantine/core"; +import type { MalformedListItem } from "@inspector/core/mcp"; import type { GetPromptResult, Prompt } from "@modelcontextprotocol/client"; import { PromptControls } from "../../groups/PromptControls/PromptControls"; import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; @@ -34,6 +35,11 @@ export interface PromptsScreenProps { onUiChange: (next: PromptsUiState) => void; onRefreshList: () => void; /** A failed list load, rendered above the sidebar list (#1953). */ + /** + * Entries dropped from this screen's list result(s) as malformed; the list + * panel warns about them above the list (#1909). + */ + malformedListItems?: MalformedListItem[]; loadError?: Error | null; /** Pagination controls rendered in the sidebar (#1721). */ pagination: ListPaginationControlsProps; @@ -138,6 +144,7 @@ export function PromptsScreen({ completionsSupported, onUiChange, onRefreshList, + malformedListItems, loadError, pagination, onGetPrompt, @@ -277,6 +284,7 @@ export function PromptsScreen({ listChanged={listChanged} onRefreshList={onRefreshList} loadError={loadError} + malformedListItems={malformedListItems} pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onSelectPrompt={handleSelectPrompt} diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index a94f9169..15c4a838 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -8,6 +8,7 @@ import { Stack, Text, } from "@mantine/core"; +import type { MalformedListItem } from "@inspector/core/mcp"; import type { ProtocolEra, ReadResourceResult, @@ -58,6 +59,11 @@ export interface ResourcesScreenProps { onUiChange: (next: ResourcesUiState) => void; onRefreshList: () => void; /** A failed list load, rendered above the sidebar list (#1953). */ + /** + * Entries dropped from this screen's list result(s) as malformed; the list + * panel warns about them above the list (#1909). + */ + malformedListItems?: MalformedListItem[]; loadError?: Error | null; /** Pagination controls rendered in the sidebar (#1721). */ pagination: ListPaginationControlsProps; @@ -172,6 +178,7 @@ export function ResourcesScreen({ subscriptionsSupported = true, onUiChange, onRefreshList, + malformedListItems, loadError, pagination, onReadResource, @@ -329,6 +336,7 @@ export function ResourcesScreen({ listChanged={listChanged} onRefreshList={onRefreshList} loadError={loadError} + malformedListItems={malformedListItems} pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onOpenSectionsChange={(value) => diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index f6dd4ed3..a8afeb76 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -1,4 +1,5 @@ import { Card, Flex, Stack, Text } from "@mantine/core"; +import type { MalformedListItem } from "@inspector/core/mcp"; import type { CallToolResult, ReadResourceResult, @@ -50,6 +51,11 @@ export interface ToolsScreenProps { /** Tools the SDK excluded from `tools/list` for invalid `x-mcp-header` * annotations (SEP-2243), shown in the sidebar with the reason (#1632). */ excludedTools?: ExcludedTool[]; + /** + * Entries dropped from a list result as malformed, across every list method. + * Each list panel filters for its own and warns about them (#1909). + */ + malformedListItems?: MalformedListItem[]; callState?: ToolCallState; ui: ToolsUiState; listChanged: boolean; @@ -147,6 +153,7 @@ const EmptyState = Text.withProps({ export function ToolsScreen({ tools, excludedTools, + malformedListItems, callState, ui, listChanged, @@ -191,6 +198,7 @@ export function ToolsScreen({ { + it("is true for a result the client received and refused", () => { + const err = new SdkError(SdkErrorCode.InvalidResult, "bad result"); + expect(isClientDecodeRejection(err)).toBe(true); + }); + + it("is true for a result type the codec cannot handle", () => { + const err = new SdkError(SdkErrorCode.UnsupportedResultType, "unknown"); + expect(isClientDecodeRejection(err)).toBe(true); + }); + + it("is false for a failure that produced no result to salvage", () => { + // A transport drop / timeout: re-listing would just fail again, and there + // is no response frame to pick entries out of. + expect(isClientDecodeRejection(new Error("socket hang up"))).toBe(false); + expect( + isClientDecodeRejection( + new SdkError(SdkErrorCode.RequestTimeout, "timed out"), + ), + ).toBe(false); + }); +}); + +describe("LenientListPageSchema", () => { + it("keeps the primitive array unvalidated while typing the cursor", () => { + const parsed = LenientListPageSchema.parse({ + resourceTemplates: [{ name: "ok" }, { name: 42 }], + nextCursor: "page-2", + }); + expect(parsed.nextCursor).toBe("page-2"); + expect(rawItemsOf(parsed, "resourceTemplates")).toHaveLength(2); + }); + + it("rejects a page whose cursor is malformed", () => { + // The walk itself depends on `nextCursor`, so a bad one is not salvageable + // — the strict error should stand rather than a partial list being kept. + expect( + LenientListPageSchema.safeParse({ tools: [], nextCursor: 7 }).success, + ).toBe(false); + }); + + it("reads an absent or non-array items key as no entries", () => { + expect(rawItemsOf({}, "tools")).toEqual([]); + expect( + rawItemsOf( + LenientListPageSchema.parse({ tools: "not-a-list" }) as never, + "tools", + ), + ).toEqual([]); + }); +}); + +describe("labelForRawItem", () => { + it("prefers a name, then a uriTemplate, uri, or title", () => { + expect(labelForRawItem({ name: "get_weather" })).toBe("get_weather"); + expect(labelForRawItem({ uriTemplate: "file:///{path}" })).toBe( + "file:///{path}", + ); + expect(labelForRawItem({ uri: "test://one" })).toBe("test://one"); + expect(labelForRawItem({ title: "Titled" })).toBe("Titled"); + }); + + it("has no label for an entry too broken to carry one", () => { + expect(labelForRawItem(null)).toBeUndefined(); + expect(labelForRawItem("just a string")).toBeUndefined(); + expect(labelForRawItem(7)).toBeUndefined(); + expect(labelForRawItem({ name: "" })).toBeUndefined(); + expect(labelForRawItem({ name: 42 })).toBeUndefined(); + }); +}); + +describe("describeIssues", () => { + it("names the offending field so the reader doesn't have to hunt", () => { + const result = ItemSchema.safeParse({ name: "t", annotations: [] }); + expect(result.success).toBe(false); + if (result.success) return; + expect(describeIssues(result.error)).toMatch( + /^annotations: .*expected object/i, + ); + }); + + it("names the expected type even when zod's message omits it", () => { + // The browser build reports a bare "Invalid input" where Node spells out + // "expected object, received array" — without the expected type the warning + // says something is wrong but not what would be right. + const terse = new z.ZodError([ + { + code: "invalid_type", + expected: "object", + path: ["annotations"], + message: "Invalid input", + // Double cast: the terse-message shape is what zod's BROWSER build + // emits, and no parse run under Node can produce it — the issue has to + // be hand-built, and `$ZodIssue`'s union doesn't accept a literal. + } as unknown as z.core.$ZodIssue, + ]); + expect(describeIssues(terse)).toBe( + "annotations: Invalid input (expected object)", + ); + }); + + it("does not repeat an expected type the message already carries", () => { + const result = ItemSchema.safeParse({ name: "t", annotations: [] }); + expect(result.success).toBe(false); + if (result.success) return; + const described = describeIssues(result.error); + expect(described.match(/expected object/g)).toHaveLength(1); + }); + + it("omits the path when the failure is the entry itself", () => { + const result = ItemSchema.safeParse("not an object"); + expect(result.success).toBe(false); + if (result.success) return; + const described = describeIssues(result.error); + expect(described).not.toMatch(/^:/); + expect(described).toMatch(/expected object/i); + }); +}); + +describe("salvageListItems", () => { + it("keeps the valid entries and reports only the broken one", () => { + const { valid, malformed } = salvageListItems({ + method: "resources/templates/list", + items: [ + { name: "empty_annotations", annotations: {} }, + { name: "array_annotations", annotations: [] }, + { name: "full_annotations", annotations: { priority: 0.8 } }, + ], + schema: ItemSchema, + }); + + expect(valid.map((item) => item.name)).toEqual([ + "empty_annotations", + "full_annotations", + ]); + expect(malformed).toEqual([ + { + method: "resources/templates/list", + index: 1, + label: "array_annotations", + reason: expect.stringMatching(/^annotations: /), + }, + ]); + }); + + it("reports an unlabelable entry by its position", () => { + const { valid, malformed } = salvageListItems({ + method: "tools/list", + items: [null], + schema: ItemSchema, + }); + expect(valid).toEqual([]); + expect(malformed[0]).toMatchObject({ index: 0 }); + expect(malformed[0]).not.toHaveProperty("label"); + }); + + it("indexes against the aggregated list, not the page", () => { + // Page 2 of a paginated walk: an index that restarted per page would point + // at the wrong entry in the list the user is looking at. + const { malformed } = salvageListItems({ + method: "tools/list", + items: [{ name: "ok" }, { name: 5 }], + schema: ItemSchema, + startIndex: 10, + }); + expect(malformed[0]?.index).toBe(11); + }); + + it("salvages nothing from a wholly conforming page", () => { + const { valid, malformed } = salvageListItems({ + method: "prompts/list", + items: [{ name: "a" }, { name: "b" }], + schema: ItemSchema, + }); + expect(valid).toHaveLength(2); + expect(malformed).toEqual([]); + }); +}); + +describe("summarizeMalformed", () => { + it("names the single dropped entry and why", () => { + expect( + summarizeMalformed([ + { + method: "resources/templates/list", + index: 1, + label: "array_annotations", + reason: "annotations: expected object, received array", + }, + ]), + ).toBe( + "Dropped 1 malformed entry — array_annotations: annotations: expected object, received array", + ); + }); + + it("counts the rest when several failed, falling back to the index", () => { + const summary = summarizeMalformed([ + { method: "tools/list", index: 0, reason: "expected object" }, + { method: "tools/list", index: 3, reason: "expected object" }, + ]); + expect(summary).toContain("Dropped 2 malformed entries"); + expect(summary).toContain("index 0"); + expect(summary).toContain("(+1 more)"); + }); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts new file mode 100644 index 00000000..d864e9b1 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { MessageLogState } from "@inspector/core/mcp/state/index.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; + +/** + * Per-item list salvage against a real connection (#1909). + * + * The reported server was PHP, whose empty `annotations` object reaches the + * wire as `[]` rather than `{}` — one non-conforming entry, and the SDK + * rejected the whole `resources/templates/list` result, so every template + * vanished behind "Couldn't load resources". + * + * A hand-rolled JSON-RPC server is used rather than a composable test server + * because the SDK's own server cannot emit these shapes: they are exactly what + * its types forbid. The transport, the SDK client, and the salvage path are all + * real. + */ + +interface ListPage { + items: unknown[]; + nextCursor?: string; +} + +function jsonRpcResult(id: unknown, result: unknown) { + return { jsonrpc: "2.0", id, result }; +} + +/** + * Minimal streamable-HTTP MCP server that answers `initialize` and the list + * methods it was configured with, returning entries verbatim (malformed ones + * included). + */ +function startMalformedServer(initialPages: { + resourceTemplates?: ListPage[]; + tools?: ListPage[]; +}): Promise<{ + url: string; + stop: () => Promise; + calls: string[]; + /** Swap what the server serves next, to model a server that got fixed. */ + setPages: (next: { + resourceTemplates?: ListPage[]; + tools?: ListPage[]; + }) => void; +}> { + const calls: string[] = []; + let pages = initialPages; + + const pageFor = (key: "resourceTemplates" | "tools", cursor?: string) => { + const configured = pages[key] ?? []; + const index = cursor === undefined ? 0 : Number(cursor); + return configured[index] ?? { items: [] }; + }; + + const handler = async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString(); + // The SDK also issues bodyless requests (the SSE GET, the session DELETE on + // teardown). Answer them without pretending they're JSON-RPC. + if (raw.length === 0) { + res.writeHead(405).end(); + return; + } + const body = JSON.parse(raw) as { + id?: unknown; + method: string; + params?: { cursor?: string }; + }; + calls.push(body.method); + + // Notifications carry no id and get an empty 202. + if (body.id === undefined) { + res.writeHead(202).end(); + return; + } + + const send = (result: unknown) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(jsonRpcResult(body.id, result))); + }; + + if (body.method === "initialize") { + send({ + protocolVersion: "2025-06-18", + capabilities: { resources: {}, tools: {} }, + serverInfo: { name: "malformed-list-server", version: "1.0.0" }, + }); + return; + } + if (body.method === "resources/templates/list") { + const page = pageFor("resourceTemplates", body.params?.cursor); + send({ + resourceTemplates: page.items, + ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }), + }); + return; + } + if (body.method === "tools/list") { + const page = pageFor("tools", body.params?.cursor); + send({ + tools: page.items, + ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }), + }); + return; + } + send({}); + }; + + const server: Server = createServer((req, res) => { + void handler(req, res); + }); + + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + /* v8 ignore next -- listen() on a fresh server always yields an AddressInfo */ + const port = typeof address === "object" && address ? address.port : 0; + resolve({ + url: `http://127.0.0.1:${port}/mcp`, + calls, + setPages: (next) => { + pages = next; + }, + stop: () => + new Promise((done) => { + server.close(() => done()); + }), + }); + }); + }); +} + +const VALID_TEMPLATE = { + name: "full_annotations", + uriTemplate: "annotated://full/{id}", + annotations: { audience: ["user"], priority: 0.8 }, +}; +// The #1909 shape: `[]` where the spec says object. +const PHP_EMPTY_ANNOTATIONS = { + name: "array_annotations", + uriTemplate: "annotated://array/{id}", + annotations: [], +}; +const EMPTY_OBJECT_ANNOTATIONS = { + name: "empty_annotations", + uriTemplate: "annotated://empty/{id}", + annotations: {}, +}; + +describe("InspectorClient list salvage (#1909)", () => { + let client: InspectorClient | null = null; + let stopServer: (() => Promise) | null = null; + + async function connectTo(url: string) { + client = new InspectorClient( + { type: "streamable-http", url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + return client; + } + + beforeEach(() => { + client = null; + stopServer = null; + }); + + afterEach(async () => { + try { + await client?.disconnect(); + } catch { + // Teardown only — the assertions already ran. + } + client = null; + await stopServer?.(); + stopServer = null; + }); + + it("keeps the valid templates and reports the malformed one", async () => { + const server = await startMalformedServer({ + resourceTemplates: [ + { + items: [ + EMPTY_OBJECT_ANNOTATIONS, + PHP_EMPTY_ANNOTATIONS, + VALID_TEMPLATE, + ], + }, + ], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + const { resourceTemplates } = await connected.listAllResourceTemplates(); + + // Before the fix this threw, and the panel showed zero templates. + expect(resourceTemplates.map((t) => t.name)).toEqual([ + "empty_annotations", + "full_annotations", + ]); + expect(connected.getMalformedListItems()).toEqual([ + { + method: "resources/templates/list", + index: 1, + label: "array_annotations", + reason: expect.stringMatching(/^annotations: /), + }, + ]); + }); + + it("accepts an empty annotations object without salvaging at all", async () => { + // `{}` is legal — every Annotations field is optional — so this must take + // the strict path and report nothing dropped. + const server = await startMalformedServer({ + resourceTemplates: [{ items: [EMPTY_OBJECT_ANNOTATIONS] }], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + const { resourceTemplates } = await connected.listAllResourceTemplates(); + expect(resourceTemplates).toHaveLength(1); + expect(connected.getMalformedListItems()).toEqual([]); + }); + + it("emits malformedListItemsChange so the UI can warn", async () => { + const server = await startMalformedServer({ + resourceTemplates: [{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE] }], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + const seen: unknown[] = []; + connected.addEventListener("malformedListItemsChange", (event) => { + seen.push(event.detail); + }); + await connected.listAllResourceTemplates(); + + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject([{ label: "array_annotations" }]); + }); + + it("marks the Protocol entry rejected even though the list rendered", async () => { + const server = await startMalformedServer({ + resourceTemplates: [{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE] }], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + const log = new MessageLogState(connected); + + await connected.listAllResourceTemplates(); + + const rejected = log + .getMessages() + .filter((entry) => entry.clientError !== undefined); + expect(rejected.length).toBeGreaterThan(0); + expect(rejected.at(-1)?.clientError).toContain("Dropped 1 malformed entry"); + log.destroy(); + }); + + it("salvages across pages, indexing against the aggregate", async () => { + const server = await startMalformedServer({ + tools: [ + { + items: [{ name: "ok_one", inputSchema: { type: "object" } }], + nextCursor: "1", + }, + { items: [{ name: "broken", inputSchema: "not-a-schema" }] }, + ], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + const { tools } = await connected.listAllTools(); + expect(tools.map((t) => t.name)).toEqual(["ok_one"]); + expect(connected.getMalformedListItems()).toMatchObject([ + { method: "tools/list", index: 1, label: "broken" }, + ]); + }); + + it("clears a previous report once the server answers cleanly", async () => { + const server = await startMalformedServer({ + resourceTemplates: [{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE] }], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + await connected.listAllResourceTemplates(); + expect(connected.getMalformedListItems()).toHaveLength(1); + + // The server is fixed; the next refresh must clear the stale report rather + // than leaving a warning about entries that are no longer wrong. + server.setPages({ resourceTemplates: [{ items: [VALID_TEMPLATE] }] }); + await connected.listAllResourceTemplates({ cacheMode: "bypass" }); + expect(connected.getMalformedListItems()).toEqual([]); + }); + + it("rethrows when the rejection is not about any single entry", async () => { + // Every entry validates; the result is bad for another reason (a cursor of + // the wrong type). There is no per-item story, so the original error must + // survive rather than being swallowed into a partial list. + const server = await startMalformedServer({ + resourceTemplates: [ + { items: [VALID_TEMPLATE], nextCursor: 42 as unknown as string }, + ], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + await expect(connected.listAllResourceTemplates()).rejects.toThrow(); + expect(connected.getMalformedListItems()).toEqual([]); + }); +}); diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index 00e3ed16..728ef5f7 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -38,6 +38,7 @@ import type { ExcludedTool, } from "../types.js"; import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../types.js"; +import type { MalformedListItem } from "../listSalvage.js"; import type { JsonValue } from "../../json/jsonUtils.js"; type ListResult = { @@ -282,6 +283,18 @@ export class FakeInspectorClient this.dispatchTypedEvent("excludedToolsChange", excluded); } + private malformedListItems: MalformedListItem[] = []; + + getMalformedListItems(): MalformedListItem[] { + return this.malformedListItems; + } + + /** Test helper: set the malformed-entry set and emit the change (#1909). */ + setMalformedListItems(malformed: MalformedListItem[]): void { + this.malformedListItems = malformed; + this.dispatchTypedEvent("malformedListItemsChange", malformed); + } + getDiscoverResult(): DiscoverResult | undefined { return this.discoverResult; } diff --git a/core/mcp/index.ts b/core/mcp/index.ts index f8e37cd3..e83f9a5c 100644 --- a/core/mcp/index.ts +++ b/core/mcp/index.ts @@ -42,6 +42,9 @@ export type { ToolCallInvocation, } from "./types.js"; +// Per-item list salvage (#1909) +export type { MalformedListItem } from "./listSalvage.js"; + // Re-export JSON utilities export type { JsonValue } from "../json/jsonUtils.js"; export { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index b0b809f3..e37994d5 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -159,10 +159,26 @@ import { ListResourcesResultSchema, ListResourceTemplatesResultSchema, ListPromptsResultSchema, + // Per-primitive item schemas — the salvage fallback validates a list's + // entries one at a time with these when the whole-result parse rejects + // (#1909, see `listAllSalvaging`). + ToolSchema, + ResourceSchema, + ResourceTemplateSchema, + PromptSchema, } from "@modelcontextprotocol/core"; import type { ClientResult } from "@modelcontextprotocol/client"; import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; +import { z } from "zod/v4"; import { validateToolOutput } from "./toolOutputValidation.js"; +import { + LenientListPageSchema, + isClientDecodeRejection, + rawItemsOf, + salvageListItems, + summarizeMalformed, + type MalformedListItem, +} from "./listSalvage.js"; import { TasksListChangedNotificationSchema } from "./taskNotificationSchemas.js"; import { type JsonValue, @@ -407,6 +423,11 @@ export class InspectorClient extends InspectorClientEventTarget { // annotations (SEP-2243), recomputed on every aggregate tools refresh and // surfaced so the Tools tab can show why a tool vanished (#1632). private excludedTools: ExcludedTool[] = []; + // Entries the Inspector dropped from a list result because they failed the + // spec schema for that primitive, keyed by list method. Populated only by the + // salvage fallback in `listAllSalvaging` — empty against a conforming server + // (#1909). + private malformedListItems = new Map(); // The capabilities this Inspector client advertises to the server during the // initialize handshake. Built once in setupClient() and snapshotted here so // UI surfaces (Server Info modal) can display them without poking at the @@ -2211,6 +2232,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.protocolEra = undefined; this.discoverResult = undefined; this.excludedTools = []; + this.malformedListItems.clear(); // Read as "not opted in" while disconnected. This is no longer what stops // it leaking into the next connection — `resetSessionState()` re-derives it // at connect, so removing this would leak nothing (#1629). Note the web @@ -2230,6 +2252,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.dispatchTypedEvent("protocolEraChange", this.protocolEra); this.dispatchTypedEvent("discoverResultChange", this.discoverResult); this.dispatchTypedEvent("excludedToolsChange", this.excludedTools); + this.dispatchTypedEvent("malformedListItemsChange", []); } /** @@ -3126,12 +3149,20 @@ export class InspectorClient extends InspectorClientEventTarget { if (!this.client) { throw new Error("Client is not connected"); } - const response = await this.invokeMcpClient(() => - this.client!.listTools( - this.aggregateListParams(options?.metadata), - this.getCacheableRequestOptions(options?.cacheMode), - ), - ); + const tools = await this.listAllSalvaging({ + method: "tools/list", + itemsKey: "tools", + itemSchema: ToolSchema, + metadata: options?.metadata, + aggregate: async () => [ + ...( + await this.client!.listTools( + this.aggregateListParams(options?.metadata), + this.getCacheableRequestOptions(options?.cacheMode), + ) + ).tools, + ], + }); // Recompute the SEP-2243 excluded-tools set alongside the aggregate. The // SDK already filtered `response.tools`, so it can't tell us what it // dropped — {@link refreshExcludedTools} re-lists the RAW `tools/list` to @@ -3153,7 +3184,7 @@ export class InspectorClient extends InspectorClientEventTarget { "Excluded-tools walk failed; the SEP-2243 excluded list may be incomplete", ); }); - return { tools: [...response.tools] }; + return { tools }; } /** @@ -3187,6 +3218,146 @@ export class InspectorClient extends InspectorClientEventTarget { this.dispatchTypedEvent("responseRejected", { id, reason }); } + /** Every entry dropped from a list result as malformed, across methods (#1909). */ + getMalformedListItems(): MalformedListItem[] { + return [...this.malformedListItems.values()].flat(); + } + + /** + * Record (or clear) the malformed entries for one list method and notify. + * + * A no-op when the method had none and still has none, so the common case — + * every conforming refresh, of which there are many — costs no event and no + * re-render. + */ + private setMalformedListItems( + method: string, + malformed: MalformedListItem[], + ): void { + const previous = this.malformedListItems.get(method) ?? []; + if (previous.length === 0 && malformed.length === 0) return; + if (malformed.length === 0) this.malformedListItems.delete(method); + else this.malformedListItems.set(method, malformed); + this.dispatchTypedEvent( + "malformedListItemsChange", + this.getMalformedListItems(), + ); + } + + /** + * Run a list aggregate, falling back to a per-item salvage when the SDK + * rejects the result it received (#1909). + * + * The strict aggregate runs first and unchanged, so a conforming server pays + * nothing: no extra request, no extra parse, and the SDK's response cache + * still serves it. Only an `InvalidResult` rejection — a response in hand + * that failed validation — reaches {@link salvageList}; every other failure + * (transport drop, timeout, server-sent error) rethrows untouched, because + * re-listing would not produce anything to salvage. + */ + private async listAllSalvaging({ + method, + itemsKey, + itemSchema, + aggregate, + metadata, + }: { + method: string; + itemsKey: string; + itemSchema: z.ZodType; + aggregate: () => Promise; + metadata?: Record; + }): Promise { + try { + const items = await this.invokeMcpClient(aggregate, { method }); + this.setMalformedListItems(method, []); + return items; + } catch (err) { + if (!isClientDecodeRejection(err)) throw err; + return this.salvageList({ method, itemsKey, itemSchema, metadata, err }); + } + } + + /** + * Re-walk a list method with pagination validated but entries left raw, then + * validate each entry on its own — keeping the good ones and reporting the + * rest (#1909). + * + * Two deliberate properties: + * + * - **The original error wins when nothing is salvageable.** If every entry + * validates here, the strict rejection was about something else (a + * top-level field, an era codec decision), so there is no per-item story to + * tell and swallowing it would hide a real failure. Rethrow the original. + * - **A salvaged response is still a rejected response.** The Protocol entry + * is marked (#1953) with what was dropped, so the wire truth survives even + * though the list now renders. + * + * This walk is deliberately un-cached (it uses the raw per-page path), which + * is fine: it only runs against a server that just returned a non-conforming + * result. + */ + private async salvageList({ + method, + itemsKey, + itemSchema, + metadata, + err, + }: { + method: string; + itemsKey: string; + itemSchema: z.ZodType; + metadata?: Record; + err: unknown; + }): Promise { + const valid: T[] = []; + const malformed: MalformedListItem[] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + do { + const effectiveMeta = this.mergeMeta(metadata); + const params = { + ...(effectiveMeta ? { _meta: effectiveMeta } : {}), + ...(cursor ? { cursor } : {}), + }; + const page = await this.invokeMcpClient( + () => + this.client!.request( + { method, params }, + LenientListPageSchema, + this.getRequestOptions(metadata?.progressToken), + ), + { method }, + ); + const salvaged = salvageListItems({ + method, + items: rawItemsOf(page, itemsKey), + schema: itemSchema, + startIndex: valid.length + malformed.length, + }); + valid.push(...salvaged.valid); + malformed.push(...salvaged.malformed); + cursor = page.nextCursor; + if (cursor !== undefined) { + /* v8 ignore next -- defensive: a spec-compliant server never repeats a cursor; this guards a non-converging server from an infinite walk (mirrors the SDK's drainList guard) */ + if (seenCursors.has(cursor)) break; + seenCursors.add(cursor); + } + } while (cursor !== undefined); + + // Nothing per-item was wrong, so the strict rejection was about something + // this fallback cannot explain — surface it rather than hide it. + if (malformed.length === 0) throw err; + + this.markResponseRejected(method, summarizeMalformed(malformed)); + this.setMalformedListItems(method, malformed); + this.logger.warn( + { method, malformed }, + "Dropped malformed entries from a list result; the rest of the list was kept", + ); + return valid; + } + /** The current SEP-2243 excluded-tools set (empty on legacy/stdio). */ getExcludedTools(): ExcludedTool[] { return this.excludedTools; @@ -4347,13 +4518,21 @@ export class InspectorClient extends InspectorClientEventTarget { if (!this.client) { throw new Error("Client is not connected"); } - const response = await this.invokeMcpClient(() => - this.client!.listResources( - this.aggregateListParams(options?.metadata), - this.getCacheableRequestOptions(options?.cacheMode), - ), - ); - return { resources: [...response.resources] }; + const resources = await this.listAllSalvaging({ + method: "resources/list", + itemsKey: "resources", + itemSchema: ResourceSchema, + metadata: options?.metadata, + aggregate: async () => [ + ...( + await this.client!.listResources( + this.aggregateListParams(options?.metadata), + this.getCacheableRequestOptions(options?.cacheMode), + ) + ).resources, + ], + }); + return { resources }; } /** @@ -4504,15 +4683,21 @@ export class InspectorClient extends InspectorClientEventTarget { if (!this.client) { throw new Error("Client is not connected"); } - const response = await this.invokeMcpClient( - () => - this.client!.listResourceTemplates( - this.aggregateListParams(options?.metadata), - this.getCacheableRequestOptions(options?.cacheMode), - ), - { method: "resources/templates/list" }, - ); - return { resourceTemplates: [...response.resourceTemplates] }; + const resourceTemplates = await this.listAllSalvaging({ + method: "resources/templates/list", + itemsKey: "resourceTemplates", + itemSchema: ResourceTemplateSchema, + metadata: options?.metadata, + aggregate: async () => [ + ...( + await this.client!.listResourceTemplates( + this.aggregateListParams(options?.metadata), + this.getCacheableRequestOptions(options?.cacheMode), + ) + ).resourceTemplates, + ], + }); + return { resourceTemplates }; } /** @@ -4559,13 +4744,21 @@ export class InspectorClient extends InspectorClientEventTarget { if (!this.client) { throw new Error("Client is not connected"); } - const response = await this.invokeMcpClient(() => - this.client!.listPrompts( - this.aggregateListParams(options?.metadata), - this.getCacheableRequestOptions(options?.cacheMode), - ), - ); - return { prompts: [...response.prompts] }; + const prompts = await this.listAllSalvaging({ + method: "prompts/list", + itemsKey: "prompts", + itemSchema: PromptSchema, + metadata: options?.metadata, + aggregate: async () => [ + ...( + await this.client!.listPrompts( + this.aggregateListParams(options?.metadata), + this.getCacheableRequestOptions(options?.cacheMode), + ) + ).prompts, + ], + }); + return { prompts }; } /** diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index d4edee16..572909c8 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -26,6 +26,7 @@ import type { ResourceSubscriptionStreamState, ExcludedTool, } from "./types.js"; +import type { MalformedListItem } from "./listSalvage.js"; import type { Tool, ServerCapabilities, @@ -62,6 +63,12 @@ export interface InspectorClientEventMap { * connections (which don't exclude) and before connect (#1632). */ excludedToolsChange: ExcludedTool[]; + /** + * Entries dropped from a list result because they failed the spec schema for + * their primitive, across every list method. Empty against a conforming + * server; a non-empty set means the list rendered without them (#1909). + */ + malformedListItemsChange: MalformedListItem[]; capabilitiesChange: ServerCapabilities | undefined; serverInfoChange: Implementation | undefined; instructionsChange: string | undefined; diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index 6a5322ea..756b560f 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -37,6 +37,7 @@ import type { Tool, } from "@modelcontextprotocol/client"; import type { JsonValue } from "../json/jsonUtils.js"; +import type { MalformedListItem } from "./listSalvage.js"; import type { InspectorClientEventTarget } from "./inspectorClientEventTarget.js"; import type { SamplingCreateMessage } from "./samplingCreateMessage.js"; import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; @@ -65,6 +66,9 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { /** Tools excluded from `tools/list` for invalid `x-mcp-header` annotations * (SEP-2243); empty on legacy/stdio connections (#1632). */ getExcludedTools(): ExcludedTool[]; + /** Entries dropped from a list result as malformed, across every list method; + * empty against a conforming server (#1909). */ + getMalformedListItems(): MalformedListItem[]; getResourceSubscriptionStreamState(): ResourceSubscriptionStreamState; getDiscoverResult(): DiscoverResult | undefined; getServerSettings(): InspectorServerSettings | undefined; diff --git a/core/mcp/listSalvage.ts b/core/mcp/listSalvage.ts new file mode 100644 index 00000000..a144c2bd --- /dev/null +++ b/core/mcp/listSalvage.ts @@ -0,0 +1,199 @@ +/** + * Per-item salvage for paginated list results (#1909). + * + * The SDK validates a list result as a whole: one non-conforming element + * rejects the entire response, so a server with a single malformed entry + * appears to have NO tools/resources/templates/prompts at all. That is exactly + * backwards for a debugging tool — the one thing the user needs to see is which + * entry is wrong and why, and the other entries are still perfectly usable. + * + * A real report: a PHP server whose empty `annotations` object reaches the wire + * as `[]` (`json_encode` of an empty array, or of `(array) new stdClass()`) + * instead of `{}` made every resource template vanish behind "Couldn't load + * resources" (#1909). + * + * The strict path is left alone. `InspectorClient.listAll*` still calls the + * SDK's cache-aware aggregate first, and only falls back to a lenient re-walk + * when that rejects a result it received — so a conforming server pays nothing, + * and the fallback is a pure recovery path. + * + * Salvaging does NOT mean accepting: a salvaged entry is dropped from the list + * and reported as {@link MalformedListItem}, and the Protocol entry is still + * marked rejected. The Inspector keeps telling the truth about the response; it + * just stops throwing away the valid part of it. + */ + +import { z } from "zod/v4"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; + +/** + * One entry the Inspector dropped from a list result because it failed the + * spec's schema for that primitive. + */ +export interface MalformedListItem { + /** The list method that carried it, e.g. `"resources/templates/list"`. */ + method: string; + /** + * Zero-based position in the aggregated list. An entry too broken to carry a + * name is still identifiable by where it sat in the response. + */ + index: number; + /** Best-effort label read off the raw entry, when it has a usable one. */ + label?: string; + /** Why it was rejected — the first validation issue, with its field path. */ + reason: string; +} + +/** + * Whether a failed fetch is the CLIENT refusing a response it received, rather + * than the request never producing one. + * + * This gates both `markResponseRejected` (#1953) and the salvage fallback + * (#1909), and the distinction is load-bearing for each. For the Protocol-entry + * marking: the correlation recovers the request id as "the last response + * received for this method", which is only the failing exchange when a response + * actually just arrived and was refused while decoding. A transport drop, a + * timeout, or an aborted request produces no response frame at all — the + * last-answered id then still points at some EARLIER, successful call, and + * marking it would stamp "Rejected by the Inspector" onto an exchange that + * succeeded. For the salvage fallback: re-walking the list only makes sense + * when there IS a result to salvage; re-issuing the request after a timeout + * would just double the wait before failing anyway. + * + * A server-sent JSON-RPC error is excluded for a different reason: it is a real + * response, so the id would be right, but the failure is the server's. Its + * entry already renders as an error from the error frame itself, and blaming + * the Inspector for it would misattribute the cause. + * + * `SdkErrorCode.InvalidResult` is exactly "a result arrived and failed + * validation for the negotiated era"; `UnsupportedResultType` is its sibling + * for a `resultType` the codec has no handling for. Both are decisions the + * client made about a frame in hand. + */ +export function isClientDecodeRejection(err: unknown): boolean { + return ( + SdkError.isInstance(err) && + (err.code === SdkErrorCode.InvalidResult || + err.code === SdkErrorCode.UnsupportedResultType) + ); +} + +/** + * A list page parsed with everything except pagination left unvalidated. + * + * `looseObject` keeps the unknown keys, so the primitive array (`tools`, + * `resources`, `resourceTemplates`, `prompts`) survives as `unknown` for + * per-item validation. Only `nextCursor` is typed, because the walk itself + * depends on it — if THAT is malformed the page is unusable and the strict + * error should stand. + */ +export const LenientListPageSchema = z.looseObject({ + nextCursor: z.string().optional(), +}); + +export type LenientListPage = z.infer; + +/** Read the primitive array out of a leniently-parsed page. */ +export function rawItemsOf(page: LenientListPage, itemsKey: string): unknown[] { + const value = (page as Record)[itemsKey]; + return Array.isArray(value) ? value : []; +} + +/** + * Best-effort human label for a raw entry: whichever identifying string field + * it happens to carry. Undefined when the entry is too broken to have one (a + * bare string, a number, a null) — the caller falls back to the index. + */ +export function labelForRawItem(value: unknown): string | undefined { + if (typeof value !== "object" || value === null) return undefined; + const record = value as Record; + for (const key of ["name", "uriTemplate", "uri", "title"]) { + const candidate = record[key]; + if (typeof candidate === "string" && candidate.length > 0) return candidate; + } + return undefined; +} + +/** + * One-line summary of why an entry failed: the first issue, prefixed with its + * field path so `annotations` is named rather than left for the reader to find + * in a wall of JSON. The path is relative to the entry, not the response, since + * the entry is what we're reporting on. + */ +export function describeIssues(error: z.ZodError): string { + const [issue] = error.issues; + /* v8 ignore next -- zod never produces a failed parse with zero issues; guards against an empty message if it ever did */ + if (!issue) return "did not match the expected shape"; + const path = issue.path.join("."); + const detail = expectedDetail(issue); + const message = detail ? `${issue.message} (${detail})` : issue.message; + return path.length > 0 ? `${path}: ${message}` : message; +} + +/** + * `expected object` — appended because the issue's own message cannot be relied + * on to carry it. zod's browser build reports a bare "Invalid input" where Node + * says "Invalid input: expected object, received array", and the expected type + * is the whole diagnostic: without it the warning says something is wrong but + * not what would be right. Skipped when the message already spells it out, so + * the Node text doesn't end up saying it twice. + */ +function expectedDetail(issue: z.core.$ZodIssue): string | undefined { + if (!("expected" in issue) || typeof issue.expected !== "string") { + return undefined; + } + const detail = `expected ${issue.expected}`; + return issue.message.includes(detail) ? undefined : detail; +} + +/** + * Validate a page's entries one at a time, keeping the ones that pass. + * + * `startIndex` is the running count across pages, so an index in the report + * refers to the aggregated list the user sees rather than resetting per page. + */ +export function salvageListItems({ + method, + items, + schema, + startIndex = 0, +}: { + method: string; + items: unknown[]; + schema: z.ZodType; + startIndex?: number; +}): { valid: T[]; malformed: MalformedListItem[] } { + const valid: T[] = []; + const malformed: MalformedListItem[] = []; + items.forEach((item, offset) => { + const parsed = schema.safeParse(item); + if (parsed.success) { + valid.push(parsed.data); + return; + } + const label = labelForRawItem(item); + malformed.push({ + method, + index: startIndex + offset, + ...(label !== undefined && { label }), + reason: describeIssues(parsed.error), + }); + }); + return { valid, malformed }; +} + +/** + * Summary line used for the Protocol entry's rejection reason, so the Network / + * Protocol view still says the response was non-conforming even though the list + * rendered (#1953 + #1909). + */ +export function summarizeMalformed(malformed: MalformedListItem[]): string { + const [first] = malformed; + /* v8 ignore next -- callers only summarize a non-empty set */ + if (!first) return ""; + const where = first.label ?? `index ${first.index}`; + const rest = malformed.length > 1 ? ` (+${malformed.length - 1} more)` : ""; + return `Dropped ${malformed.length} malformed ${ + malformed.length === 1 ? "entry" : "entries" + } — ${where}: ${first.reason}${rest}`; +} diff --git a/core/mcp/state/managedListState.ts b/core/mcp/state/managedListState.ts index ea5128b5..eaf51f5d 100644 --- a/core/mcp/state/managedListState.ts +++ b/core/mcp/state/managedListState.ts @@ -17,7 +17,7 @@ import type { CacheMode, ServerCapabilities, } from "@modelcontextprotocol/client"; -import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; +import { isClientDecodeRejection } from "../listSalvage.js"; import { isTerminalStatus } from "../types.js"; import { TypedEventTarget } from "../typedEventTarget.js"; @@ -30,38 +30,6 @@ import { TypedEventTarget } from "../typedEventTarget.js"; */ export const DEFAULT_LIST_CHANGED_DEBOUNCE_MS = 250; -/** - * Whether a failed fetch is the CLIENT refusing a response it received, rather - * than the request never producing one. - * - * This gates `markResponseRejected`, and the distinction is load-bearing. That - * correlation recovers the request id as "the last response received for this - * method", which is only the failing exchange when a response actually just - * arrived and was refused while decoding. A transport drop, a timeout, or an - * aborted request produces no response frame at all — the last-answered id then - * still points at some EARLIER, successful call, and marking it would stamp - * "Rejected by the Inspector" onto an exchange that succeeded. That is the very - * class of lie this issue exists to remove, so it must not be traded for - * another one. - * - * A server-sent JSON-RPC error is excluded for a different reason: it is a real - * response, so the id would be right, but the failure is the server's. Its - * entry already renders as an error from the error frame itself, and blaming - * the Inspector for it would misattribute the cause. - * - * `SdkErrorCode.InvalidResult` is exactly "a result arrived and failed - * validation for the negotiated era"; `UnsupportedResultType` is its sibling - * for a `resultType` the codec has no handling for. Both are decisions the - * client made about a frame in hand. - */ -function isClientDecodeRejection(err: unknown): boolean { - return ( - SdkError.isInstance(err) && - (err.code === SdkErrorCode.InvalidResult || - err.code === SdkErrorCode.UnsupportedResultType) - ); -} - /** * Every managed-list event map carries the list-changed indicator event and the * last-fetch error (#1953). diff --git a/core/react/useInspectorClient.ts b/core/react/useInspectorClient.ts index db0910c3..5e9de64a 100644 --- a/core/react/useInspectorClient.ts +++ b/core/react/useInspectorClient.ts @@ -11,6 +11,7 @@ import type { DiscoverResult, } from "@modelcontextprotocol/client"; import type { ExcludedTool } from "../mcp/types.js"; +import type { MalformedListItem } from "../mcp/listSalvage.js"; // Module-scope frozen object so the `?? EMPTY_CLIENT_CAPABILITIES` // fallback below doesn't return a fresh literal on every render — @@ -44,6 +45,12 @@ export interface UseInspectorClientResult { * connections and before connect (#1632). */ excludedTools: ExcludedTool[]; + /** + * Entries the Inspector dropped from a list result because they failed the + * spec schema for their primitive. Empty against a conforming server; a + * non-empty set means that list rendered without them (#1909). + */ + malformedListItems: MalformedListItem[]; /** * Message from the most recent mid-session transport failure (the client's * `error` event — stdio crash, SSE drop, HTTP 5xx). Stays set until the next @@ -97,6 +104,9 @@ export function useInspectorClient( const [excludedTools, setExcludedTools] = useState( inspectorClient?.getExcludedTools() ?? [], ); + const [malformedListItems, setMalformedListItems] = useState< + MalformedListItem[] + >(inspectorClient?.getMalformedListItems() ?? []); const [lastError, setLastError] = useState(undefined); useEffect(() => { @@ -109,6 +119,7 @@ export function useInspectorClient( setProtocolEra(undefined); setDiscoverResult(undefined); setExcludedTools([]); + setMalformedListItems([]); setLastError(undefined); return; } @@ -121,6 +132,7 @@ export function useInspectorClient( setProtocolEra(inspectorClient.getProtocolEra()); setDiscoverResult(inspectorClient.getDiscoverResult()); setExcludedTools(inspectorClient.getExcludedTools()); + setMalformedListItems(inspectorClient.getMalformedListItems()); setLastError(undefined); const onStatusChange = (event: TypedEvent<"statusChange">) => { @@ -156,6 +168,11 @@ export function useInspectorClient( ) => { setDiscoverResult(event.detail); }; + const onMalformedListItemsChange = ( + event: TypedEvent<"malformedListItemsChange">, + ) => { + setMalformedListItems(event.detail); + }; const onExcludedToolsChange = ( event: TypedEvent<"excludedToolsChange">, ) => { @@ -186,6 +203,10 @@ export function useInspectorClient( "excludedToolsChange", onExcludedToolsChange, ); + inspectorClient.addEventListener( + "malformedListItemsChange", + onMalformedListItemsChange, + ); return () => { inspectorClient.removeEventListener("statusChange", onStatusChange); @@ -218,6 +239,10 @@ export function useInspectorClient( "excludedToolsChange", onExcludedToolsChange, ); + inspectorClient.removeEventListener( + "malformedListItemsChange", + onMalformedListItemsChange, + ); }; }, [inspectorClient]); @@ -247,6 +272,7 @@ export function useInspectorClient( protocolEra, discoverResult, excludedTools, + malformedListItems, lastError, appRendererClient: inspectorClient?.getAppRendererClient() ?? null, connect, From 17a4c78dd3e787293539d619ed3c05cf28c9f251 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 16:59:37 -0400 Subject: [PATCH 14/58] fix: centralize the nullable-union collapse in core and apply it in the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2015, folded into #1928 rather than left as a follow-up. The TUI's `schemaToForm` has the same defect the web `SchemaForm` had: its local `JsonSchemaProperty` declares only `type?: string` — no `anyOf`, no array-valued `type` — so a `.nullish()` argument has no top-level `type` or `enum` for the dispatcher to read and falls to the `switch` default, losing its select. Milder than the web bug (a plain text field, not a corrupting JSON textarea), but the same class. Fixing it in a second place is the argument for not having the logic in a first place. So the collapse moves to `core/json/nullableUnion.ts` as `normalizeNullableUnion`, generic over a minimal `NullableUnionSchema` shape — the least a caller must expose to be *recognized* as a nullable union — so neither client has to adopt the other's schema type. Both form builders now share one copy and cannot drift on which schemas they render. - `core/json/nullableUnion.ts` — the shared collapse, plus tests at `clients/web/src/test/core/nullableUnion.test.ts` (the web suite is what gates `core/`). - `clients/web`: `schemaUtils.normalizeUnionType` is gone; `SchemaForm` and `resolveRefsInMessage` call the core function directly. - `clients/tui`: `schemaToForm` normalizes each property before dispatch, and `JsonSchemaProperty` grows the `anyOf` / array-`type` fields the encoding uses. Verified by driving the built TUI under a pty against the `nullable-fields-http.json` showcase server. Before, every argument of `record_shipment` rendered as a plain text field; after, `direction` is a select ("No value"), `quantity` an integer, `express` a boolean ("[Not set]"). Signed-off-by: cliffhall --- AGENTS.md | 10 +- README.md | 5 +- clients/tui/__tests__/schemaToForm.test.ts | 113 ++++++++ clients/tui/src/utils/schemaToForm.ts | 23 +- .../groups/SchemaForm/SchemaForm.tsx | 4 +- .../web/src/test/core/nullableUnion.test.ts | 250 ++++++++++++++++++ clients/web/src/utils/schemaUtils.test.ts | 221 ---------------- clients/web/src/utils/schemaUtils.ts | 84 +----- core/json/nullableUnion.ts | 120 +++++++++ 9 files changed, 519 insertions(+), 311 deletions(-) create mode 100644 clients/web/src/test/core/nullableUnion.test.ts create mode 100644 core/json/nullableUnion.ts diff --git a/AGENTS.md b/AGENTS.md index 0baea459..87d51230 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,15 @@ v2/main/ │ │ # annotation scan/validation + mirrored-param │ │ # derivation, used by the Tools tab — #1632; │ │ # plus `Mcp-Param-*` header building for the -│ │ # wire, used by both `tools/call` paths — #1846) +│ │ # wire, used by both `tools/call` paths — #1846; +│ │ # nullableUnion.ts: collapses the two JSON Schema +│ │ # encodings of "T or null" (`anyOf` with a null +│ │ # branch, `type: [T,"null"]`) into a plain type, +│ │ # hoisting the surviving branch's own keywords. +│ │ # Shared by BOTH form builders — web SchemaForm +│ │ # and TUI schemaToForm — since each dispatches on +│ │ # a single `type` string and would otherwise miss +│ │ # a nullable field entirely — #1928/#2015) │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime + state stores │ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks diff --git a/README.md b/README.md index 82164a98..467a4fc0 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ inspector/ ├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json) │ ├── auth/ # OAuth: providers, discovery, storage, mid-session recovery (browser/node/remote backends) │ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets -│ ├── json/ # JSON + parameter/argument conversion utilities +│ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union +│ │ # schema collapse shared by the web and TUI form builders │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime, state stores, transports, config import │ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection) @@ -234,6 +235,8 @@ The duplicated copies are appended rather than placed beside their twin on purpo Open the Tools tab and select `record_shipment`: `direction` must render as a **Select** (`envio` / `recebimento`) with a clear button that sets it back to `null`, `reference` as a text input, `quantity` as a number input, and `express` as a checkbox. On the broken build every one of them fell through to the raw-JSON textarea, which re-escaped its own contents on each keystroke until the value was unusable ([#1928](https://github.com/modelcontextprotocol/inspector/issues/1928)). The tool echoes the arguments it received, so the result panel shows exactly what was sent. +The **TUI** had the same gap and is worth checking against the same server (`--tui`, then test `record_shipment`): `direction` is a select, `quantity` an integer field, `express` a boolean. Both clients now share one collapse step — `normalizeNullableUnion` in [`core/json/nullableUnion.ts`](./core/json/nullableUnion.ts) — precisely so they cannot drift on which schemas they can render. + #### Advertised extensions `advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`. diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index 7efca937..93ca1ca9 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -213,4 +213,117 @@ describe("schemaToForm", () => { ); expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "hi" }); }); + + // #2015 (the TUI twin of #1928): an argument declared with Zod's `.nullish()` + // has no top-level `type`/`enum` — they sit on the surviving `anyOf` branch — + // so before normalization every one of these degraded to a plain text field. + describe("nullable unions", () => { + it("renders a select for an anyOf string-enum|null argument", () => { + const form = schemaToForm( + { + properties: { + direction: { + anyOf: [ + { type: "string", enum: ["envio", "recebimento"] }, + { type: "null" }, + ], + }, + }, + }, + "nullableEnum", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ + name: "direction", + type: "select", + options: [ + { label: "envio", value: "envio" }, + { label: "recebimento", value: "recebimento" }, + ], + }); + }); + + it("maps the remaining nullable scalar branches to their typed fields", () => { + const form = schemaToForm( + { + properties: { + reference: { anyOf: [{ type: "string" }, { type: "null" }] }, + quantity: { + anyOf: [ + { type: "integer", minimum: 1, maximum: 9 }, + { type: "null" }, + ], + }, + ratio: { anyOf: [{ type: "number" }, { type: "null" }] }, + express: { anyOf: [{ type: "boolean" }, { type: "null" }] }, + }, + }, + "nullableScalars", + ); + const fields = form.sections[0]!.fields; + expect(fields.map((f) => ({ name: f.name, type: f.type }))).toEqual([ + { name: "reference", type: "string" }, + { name: "quantity", type: "integer" }, + { name: "ratio", type: "float" }, + { name: "express", type: "boolean" }, + ]); + // The branch's own constraints are hoisted along with its type. + expect(fields[1]).toMatchObject({ min: 1, max: 9 }); + }); + + it("renders a select for a nullable array-of-enum argument", () => { + const form = schemaToForm( + { + properties: { + tags: { + anyOf: [ + { type: "array", items: { enum: ["a", "b"] } }, + { type: "null" }, + ], + }, + }, + }, + "nullableArrayEnum", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ + type: "select", + options: [ + { label: "a", value: "a" }, + { label: "b", value: "b" }, + ], + }); + }); + + it("collapses the type: [T, null] encoding too", () => { + const form = schemaToForm( + { + properties: { + note: { type: ["string", "null"] }, + flag: { type: ["boolean", "null"] }, + }, + }, + "typeArrayNull", + ); + expect( + form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })), + ).toEqual([ + { name: "note", type: "string" }, + { name: "flag", type: "boolean" }, + ]); + }); + + it("leaves a union of two real types as a plain string field", () => { + const form = schemaToForm( + { + properties: { + mixed: { anyOf: [{ type: "string" }, { type: "number" }] }, + }, + }, + "realUnion", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ + name: "mixed", + type: "string", + }); + }); + }); }); diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index bbb730f2..5201abbb 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -3,10 +3,16 @@ */ import type { FormStructure, FormSection, FormField } from "ink-form"; +import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; /** Minimal JSON Schema property shape used when building tool parameter forms */ interface JsonSchemaProperty { - type?: string; + /** + * An array here is the `["string", "null"]` nullable encoding, which + * {@link normalizeNullableUnion} collapses to a single name before the + * dispatch below reads it. + */ + type?: string | string[]; title?: string; enum?: unknown[]; /** Non-standard legacy support: titles for enum values */ @@ -15,6 +21,8 @@ interface JsonSchemaProperty { minimum?: number; maximum?: number; default?: unknown; + /** Present on a nullable union; see {@link normalizeNullableUnion}. */ + anyOf?: readonly unknown[]; } /** @@ -68,9 +76,16 @@ export function schemaToForm( // `properties` values are `unknown` (the SDK schema admits anything), so // guard before treating a value as a schema object — a malformed server // schema with e.g. `properties: { foo: null }` must not throw on `.title`. - const property = ( - typeof prop === "object" && prop !== null ? prop : {} - ) as JsonSchemaProperty; + // Flatten a nullable union (`anyOf: [X, {type:"null"}]`, `type: [X,"null"]`) + // before dispatching. Every branch below reads a single `type` string, and + // for an enum the `enum` keyword sits on the union's surviving branch — so + // without this an argument declared with Zod's `.nullish()` loses its + // select and degrades to a plain text field (#2015, the TUI twin of #1928). + const property = normalizeNullableUnion( + (typeof prop === "object" && prop !== null + ? prop + : {}) as JsonSchemaProperty, + ); const baseField = { name: key, label: property.title || key, diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index eadb0564..628f2ee3 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -12,7 +12,7 @@ import { useState } from "react"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import { useValueChange } from "../../../hooks/useValueChange"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; -import { normalizeUnionType } from "../../../utils/schemaUtils"; +import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; const FieldLabel = Text.withProps({ fw: 500, @@ -224,7 +224,7 @@ export function SchemaForm({ // without this an "optional and explicitly nullable" field — what Zod's // `.nullish()` emits — matches nothing and falls through to the raw-JSON // fallback, which is unusable for a value the user has to type (#1928). - const fieldSchema = normalizeUnionType(rawSchema); + const fieldSchema = normalizeNullableUnion(rawSchema); const isRequired = requiredFields.includes(fieldName); const label = fieldSchema.title ?? fieldName; const description = fieldSchema.description; diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts new file mode 100644 index 00000000..f56abbdd --- /dev/null +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect } from "vitest"; +import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; + +// The two JSON Schema encodings of "this type, or null". Both form builders — +// the web `SchemaForm` and the TUI's `schemaToForm` — dispatch on a single +// `type` string, so this collapse is what keeps a nullable field renderable +// (#1928 in the web client, #2015 in the TUI). +describe("normalizeNullableUnion", () => { + it("normalizes anyOf string|null", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "string" }, { type: "null" }], + }), + ).toEqual({ + type: "string", + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes anyOf boolean|null", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "boolean" }, { type: "null" }], + }), + ).toEqual({ + type: "boolean", + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes anyOf number|null", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "number" }, { type: "null" }], + }), + ).toEqual({ + type: "number", + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes anyOf integer|null", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "integer" }, { type: "null" }], + }), + ).toEqual({ + type: "integer", + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes anyOf array|null", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "array" }, { type: "null" }], + }), + ).toEqual({ + type: "array", + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes type array string|null", () => { + expect(normalizeNullableUnion({ type: ["string", "null"] })).toEqual({ + type: "string", + nullable: true, + }); + }); + + it("normalizes type array boolean|null", () => { + expect(normalizeNullableUnion({ type: ["boolean", "null"] })).toEqual({ + type: "boolean", + nullable: true, + }); + }); + + it("normalizes type array number|null", () => { + expect(normalizeNullableUnion({ type: ["number", "null"] })).toEqual({ + type: "number", + nullable: true, + }); + }); + + it("normalizes type array integer|null", () => { + expect(normalizeNullableUnion({ type: ["integer", "null"] })).toEqual({ + type: "integer", + nullable: true, + }); + }); + + it("returns schema unchanged when no union pattern matches", () => { + const schema = { type: "string" as const }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("ignores anyOf with more than two members", () => { + const schema = { + anyOf: [ + { type: "string" as const }, + { type: "null" as const }, + { type: "number" as const }, + ], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("ignores a two-member anyOf with no null branch", () => { + const schema = { + anyOf: [{ type: "string" as const }, { type: "number" as const }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("ignores an anyOf whose non-null branch has no renderable type (#1928)", () => { + const schema = { + anyOf: [{ const: "only" }, { type: "null" as const }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + // #1928: the enum lives on the surviving branch, and hoisting it is what makes + // the field render as a Select instead of the raw-JSON fallback. + it("hoists enum out of an anyOf string-enum|null branch", () => { + expect( + normalizeNullableUnion({ + description: "Direction", + anyOf: [ + { type: "string", enum: ["envio", "recebimento"] }, + { type: "null" }, + ], + }), + ).toEqual({ + type: "string", + description: "Direction", + enum: ["envio", "recebimento"], + anyOf: undefined, + nullable: true, + }); + }); + + it("infers string for a typeless enum branch", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ enum: ["a", "b"] }, { type: "null" }], + }), + ).toEqual({ + type: "string", + enum: ["a", "b"], + anyOf: undefined, + nullable: true, + }); + }); + + it("hoists items out of an anyOf array|null branch", () => { + expect( + normalizeNullableUnion({ + anyOf: [ + { type: "array", items: { type: "string", enum: ["a", "b"] } }, + { type: "null" }, + ], + }), + ).toEqual({ + type: "array", + items: { type: "string", enum: ["a", "b"] }, + anyOf: undefined, + nullable: true, + }); + }); + + it("hoists properties out of an anyOf object|null branch", () => { + expect( + normalizeNullableUnion({ + anyOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "null" }, + ], + }), + ).toEqual({ + type: "object", + properties: { a: { type: "string" } }, + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes anyOf object|null regardless of branch order", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "null" }, { type: "object" }], + }), + ).toEqual({ type: "object", anyOf: undefined, nullable: true }); + }); + + it("normalizes type array object|null", () => { + expect( + normalizeNullableUnion({ + type: ["object", "null"], + properties: { a: { type: "string" } }, + }), + ).toEqual({ + type: "object", + properties: { a: { type: "string" } }, + nullable: true, + }); + }); + + it("normalizes type array array|null", () => { + expect(normalizeNullableUnion({ type: ["array", "null"] })).toEqual({ + type: "array", + nullable: true, + }); + }); + + it("keeps a type array of two non-null members unchanged", () => { + const schema = { type: ["string", "number"] as const }; + expect( + normalizeNullableUnion({ ...schema, type: [...schema.type] }), + ).toEqual({ + type: ["string", "number"], + }); + }); + + // A server can put anything in `anyOf`; a non-object member must not be read + // as a branch (nor throw), it simply means no nullable union was recognized. + it("ignores an anyOf holding a non-object member", () => { + const schema = { anyOf: ["string", { type: "null" as const }] }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("ignores an anyOf holding an array member", () => { + const schema = { anyOf: [["string"], { type: "null" as const }] }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("ignores a type array whose non-null member has no widget", () => { + const schema = { type: ["null", "null"] }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("ignores a schema with no union keywords at all", () => { + const schema = { type: "object" as const, properties: {} }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); +}); diff --git a/clients/web/src/utils/schemaUtils.test.ts b/clients/web/src/utils/schemaUtils.test.ts index 59623fdb..7ff38c88 100644 --- a/clients/web/src/utils/schemaUtils.test.ts +++ b/clients/web/src/utils/schemaUtils.test.ts @@ -8,7 +8,6 @@ import { generateDefaultValue, isPropertyRequired, resolveRef, - normalizeUnionType, formatFieldLabel, resolveRefsInMessage, } from "./schemaUtils"; @@ -243,226 +242,6 @@ describe("resolveRef", () => { }); }); -describe("normalizeUnionType", () => { - it("normalizes anyOf string|null", () => { - expect( - normalizeUnionType({ - anyOf: [{ type: "string" }, { type: "null" }], - }), - ).toEqual({ - type: "string", - anyOf: undefined, - nullable: true, - }); - }); - - it("normalizes anyOf boolean|null", () => { - expect( - normalizeUnionType({ - anyOf: [{ type: "boolean" }, { type: "null" }], - }), - ).toEqual({ - type: "boolean", - anyOf: undefined, - nullable: true, - }); - }); - - it("normalizes anyOf number|null", () => { - expect( - normalizeUnionType({ - anyOf: [{ type: "number" }, { type: "null" }], - }), - ).toEqual({ - type: "number", - anyOf: undefined, - nullable: true, - }); - }); - - it("normalizes anyOf integer|null", () => { - expect( - normalizeUnionType({ - anyOf: [{ type: "integer" }, { type: "null" }], - }), - ).toEqual({ - type: "integer", - anyOf: undefined, - nullable: true, - }); - }); - - it("normalizes anyOf array|null", () => { - expect( - normalizeUnionType({ - anyOf: [{ type: "array" }, { type: "null" }], - }), - ).toEqual({ - type: "array", - anyOf: undefined, - nullable: true, - }); - }); - - it("normalizes type array string|null", () => { - expect(normalizeUnionType({ type: ["string", "null"] })).toEqual({ - type: "string", - nullable: true, - }); - }); - - it("normalizes type array boolean|null", () => { - expect(normalizeUnionType({ type: ["boolean", "null"] })).toEqual({ - type: "boolean", - nullable: true, - }); - }); - - it("normalizes type array number|null", () => { - expect(normalizeUnionType({ type: ["number", "null"] })).toEqual({ - type: "number", - nullable: true, - }); - }); - - it("normalizes type array integer|null", () => { - expect(normalizeUnionType({ type: ["integer", "null"] })).toEqual({ - type: "integer", - nullable: true, - }); - }); - - it("returns schema unchanged when no union pattern matches", () => { - const schema = { type: "string" as const }; - expect(normalizeUnionType(schema)).toBe(schema); - }); - - it("ignores anyOf with more than two members", () => { - const schema = { - anyOf: [ - { type: "string" as const }, - { type: "null" as const }, - { type: "number" as const }, - ], - }; - expect(normalizeUnionType(schema)).toBe(schema); - }); - - it("ignores a two-member anyOf with no null branch", () => { - const schema = { - anyOf: [{ type: "string" as const }, { type: "number" as const }], - }; - expect(normalizeUnionType(schema)).toBe(schema); - }); - - it("ignores an anyOf whose non-null branch has no renderable type (#1928)", () => { - const schema = { - anyOf: [{ const: "only" }, { type: "null" as const }], - }; - expect(normalizeUnionType(schema)).toBe(schema); - }); - - // #1928: the enum lives on the surviving branch, and hoisting it is what makes - // the field render as a Select instead of the raw-JSON fallback. - it("hoists enum out of an anyOf string-enum|null branch", () => { - expect( - normalizeUnionType({ - description: "Direction", - anyOf: [ - { type: "string", enum: ["envio", "recebimento"] }, - { type: "null" }, - ], - }), - ).toEqual({ - type: "string", - description: "Direction", - enum: ["envio", "recebimento"], - anyOf: undefined, - nullable: true, - }); - }); - - it("infers string for a typeless enum branch", () => { - expect( - normalizeUnionType({ - anyOf: [{ enum: ["a", "b"] }, { type: "null" }], - }), - ).toEqual({ - type: "string", - enum: ["a", "b"], - anyOf: undefined, - nullable: true, - }); - }); - - it("hoists items out of an anyOf array|null branch", () => { - expect( - normalizeUnionType({ - anyOf: [ - { type: "array", items: { type: "string", enum: ["a", "b"] } }, - { type: "null" }, - ], - }), - ).toEqual({ - type: "array", - items: { type: "string", enum: ["a", "b"] }, - anyOf: undefined, - nullable: true, - }); - }); - - it("hoists properties out of an anyOf object|null branch", () => { - expect( - normalizeUnionType({ - anyOf: [ - { type: "object", properties: { a: { type: "string" } } }, - { type: "null" }, - ], - }), - ).toEqual({ - type: "object", - properties: { a: { type: "string" } }, - anyOf: undefined, - nullable: true, - }); - }); - - it("normalizes anyOf object|null regardless of branch order", () => { - expect( - normalizeUnionType({ - anyOf: [{ type: "null" }, { type: "object" }], - }), - ).toEqual({ type: "object", anyOf: undefined, nullable: true }); - }); - - it("normalizes type array object|null", () => { - expect( - normalizeUnionType({ - type: ["object", "null"], - properties: { a: { type: "string" } }, - }), - ).toEqual({ - type: "object", - properties: { a: { type: "string" } }, - nullable: true, - }); - }); - - it("normalizes type array array|null", () => { - expect(normalizeUnionType({ type: ["array", "null"] })).toEqual({ - type: "array", - nullable: true, - }); - }); - - it("keeps a type array of two non-null members unchanged", () => { - const schema = { type: ["string", "number"] as const }; - expect(normalizeUnionType({ ...schema, type: [...schema.type] })).toEqual({ - type: ["string", "number"], - }); - }); -}); - describe("formatFieldLabel", () => { it("inserts spaces before capitals and capitalizes first letter", () => { expect(formatFieldLabel("firstName")).toBe("First Name"); diff --git a/clients/web/src/utils/schemaUtils.ts b/clients/web/src/utils/schemaUtils.ts index 2624fba1..b03582c0 100644 --- a/clients/web/src/utils/schemaUtils.ts +++ b/clients/web/src/utils/schemaUtils.ts @@ -3,6 +3,7 @@ import Ajv from "ajv"; import type { ValidateFunction } from "ajv"; import type { Tool, JSONRPCMessage } from "@modelcontextprotocol/client"; import { isJSONRPCRequest } from "@modelcontextprotocol/client"; +import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; const ajv = new Ajv(); @@ -200,87 +201,6 @@ export function resolveRef( return schema; } -/** - * The `type` values {@link SchemaForm} dispatches on. `"null"` is deliberately - * absent: it is the branch a nullable union is being flattened *away* from, and - * there is no widget for a field whose only permitted value is `null`. - */ -const RENDERABLE_TYPES = [ - "string", - "number", - "integer", - "boolean", - "array", - "object", -] as const; - -type RenderableType = (typeof RENDERABLE_TYPES)[number]; - -function isRenderableType(type: unknown): type is RenderableType { - return RENDERABLE_TYPES.includes(type as RenderableType); -} - -/** - * Normalizes a nullable union (`string|null` from FastMCP, or anything Zod's - * `.nullish()` / `.nullable()` compiles to) into the plain type the form - * renderer dispatches on. - * - * Two encodings mean the same thing and are both flattened to - * `{ type: , nullable: true }`: - * - * - `anyOf: [, { type: "null" }]` — the branch's *own* keywords are - * hoisted onto the result, because that is where the detail the renderer - * needs lives. A nullable enum compiles to - * `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`, so hoisting - * `enum` is what makes it a `Select` instead of falling through to the raw - * JSON fallback (#1928). The branch also wins over the wrapper on any shared - * key, matching v1.x's behavior. - * - `type: [, "null"]` — the keywords already sit at the top level, so only - * `type` collapses. - * - * Anything else — a union of two real types, a three-member `anyOf`, a branch - * whose type has no widget — is returned untouched and renders through the - * JSON fallback, which is the honest representation of a shape the form cannot - * model. - * - * @param schema The JSON schema to normalize - * @returns A normalized schema, or the original schema when no nullable-union - * pattern matches - */ -export function normalizeUnionType( - schema: InspectorFormSchema, -): InspectorFormSchema { - if (schema.anyOf && schema.anyOf.length === 2) { - // `JsonSchemaConst` is structurally a subset of `InspectorFormSchema` (it - // just carries no `type`), so reading both as the latter is safe. - const branches = schema.anyOf as InspectorFormSchema[]; - const nullBranch = branches.find((branch) => branch?.type === "null"); - const branch = branches.find((candidate) => candidate?.type !== "null"); - - if (nullBranch && branch) { - // A bare `{ enum: [...] }` branch carries no `type`; JSON Schema allows - // that, and every value such a schema admits here is a string. - const type = branch.type ?? (branch.enum ? "string" : undefined); - if (isRenderableType(type)) { - return { ...schema, ...branch, type, anyOf: undefined, nullable: true }; - } - } - } - - if ( - Array.isArray(schema.type) && - schema.type.length === 2 && - schema.type.includes("null") - ) { - const type = schema.type.find((member) => member !== "null"); - if (isRenderableType(type)) { - return { ...schema, type, nullable: true }; - } - } - - return schema; -} - /** * Formats a field key into a human-readable label * @param key The field key to format @@ -319,7 +239,7 @@ export function resolveRefsInMessage(message: JSONRPCMessage): JSONRPCMessage { Object.entries(requestedSchema.properties).map( ([key, propSchema]) => { const resolved = resolveRef(propSchema, requestedSchema); - const normalized = normalizeUnionType(resolved); + const normalized = normalizeNullableUnion(resolved); return [key, normalized]; }, ), diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts new file mode 100644 index 00000000..4e81b791 --- /dev/null +++ b/core/json/nullableUnion.ts @@ -0,0 +1,120 @@ +/** + * Flattening of the two JSON Schema encodings of "this type, **or** null" into + * the plain type a form renderer can dispatch on. + * + * Shared because both form builders — the web client's `SchemaForm` and the + * TUI's `schemaToForm` — dispatch on a *single* `type` string, and so both miss + * a nullable field entirely without this step (#1928, #2015). Keeping one copy + * is what stops the two clients from disagreeing about which schemas they can + * render; the alternative was two implementations of the same subtle predicate. + */ + +/** + * JSON Schema type names a form renderer can build a widget for. + * + * `"null"` is deliberately absent: it is the branch a nullable union is being + * flattened *away* from, and there is no widget for a field whose only + * permitted value is `null`. + */ +const RENDERABLE_TYPES = [ + "string", + "number", + "integer", + "boolean", + "array", + "object", +] as const; + +type RenderableType = (typeof RENDERABLE_TYPES)[number]; + +function isRenderableType(type: unknown): type is RenderableType { + return RENDERABLE_TYPES.includes(type as RenderableType); +} + +/** + * The keywords {@link normalizeNullableUnion} reads. Both clients' own schema + * types are structurally assignable to this, so neither has to adopt the + * other's — the shape is deliberately the minimum needed to *recognize* a + * nullable union, not a full JSON Schema model. + */ +export interface NullableUnionSchema { + type?: string | string[]; + enum?: unknown[]; + anyOf?: readonly unknown[]; +} + +/** Narrow an `anyOf` member to a readable object, or `null` if it isn't one. */ +function toBranch(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + return value as Record; +} + +/** + * Collapse a nullable union — what Zod's `.nullish()` / `.nullable()` and + * FastMCP's optional arguments emit — into `{ type: , nullable: true }`. + * + * Two encodings mean the same thing and are both handled: + * + * - `anyOf: [, { type: "null" }]` — the branch's *own* keywords are + * hoisted onto the result, because that is where the detail a renderer needs + * lives. A nullable enum compiles to + * `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`, so hoisting + * `enum` is what makes it a dropdown rather than a raw-JSON fallback (#1928). + * The branch also wins over the wrapper on any shared key, matching v1.x. + * - `type: [, "null"]` — the keywords already sit at the top level, so only + * `type` collapses. + * + * Anything else — a union of two real types, a three-member `anyOf`, a branch + * whose type has no widget — is returned **by identity**, so a caller can use + * `===` to tell that nothing was recognized. + * + * @param schema The schema to normalize + * @returns A flattened copy, or `schema` itself when no nullable-union pattern + * matches + */ +export function normalizeNullableUnion( + schema: T, +): T { + if (schema.anyOf?.length === 2) { + const branches = schema.anyOf.map(toBranch); + const nullBranch = branches.find((entry) => entry?.type === "null"); + const branch = branches.find( + (entry) => entry !== null && entry.type !== "null", + ); + + if (nullBranch && branch) { + // A bare `{ enum: [...] }` branch carries no `type`; JSON Schema allows + // that, and every value such a schema admits here is a string. + const type = branch.type ?? (branch.enum ? "string" : undefined); + if (isRenderableType(type)) { + // The result is `schema` with `type` narrowed to a value it already + // admitted, the branch's keywords merged in, and `nullable` added. TS + // cannot express "a spread of T and a subset of T is still a T", and a + // non-generic return would force every caller to cast instead. + return { + ...schema, + ...branch, + type, + anyOf: undefined, + nullable: true, + } as T; + } + } + } + + if ( + Array.isArray(schema.type) && + schema.type.length === 2 && + schema.type.includes("null") + ) { + const type = schema.type.find((member) => member !== "null"); + if (isRenderableType(type)) { + // Same reasoning as above. + return { ...schema, type, nullable: true } as T; + } + } + + return schema; +} From e4e16bb3e6783a7ca08e9072e4c899cd33b6544c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 17:24:46 -0400 Subject: [PATCH 15/58] fix: address Copilot review on the nullable-union collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, all valid: 1. Returning `T` was unsound. The collapse genuinely changes two fields — `type` stops being an array, `anyOf` becomes `undefined` — so a caller whose `T` typed either that way could keep treating it so afterwards. The result is now `T | NormalizedNullableUnion`, which states exactly that: `Omit` plus a `RenderableType` `type`, a cleared `anyOf`, and `nullable`. The one assertion left is isolated in a `collapsed()` helper, needed only because `Omit` stays deferred while `T` is open. 2. A typeless `enum` does not imply strings. JSON Schema allows `enum: [1, 2]`, and inferring `"string"` for it would hand numbers to a `Select` declared `string[]`, and make the TUI submit `"1"` for `1`. Now only an all-string enum earns the inference; anything else stays on the JSON fallback. An explicit `type` is still authoritative. 3. `hasMissingRequiredFields` counted every null as missing, so clearing a *required* nullable enum — newly possible via the clear button — would disable submit on a value the schema permits. `required` constrains presence, not content, so null now counts as present exactly when the field's schema admits it. 4. `collectSchemaDefaults` read the raw union, so a default nested inside a nullable object's branch was displayed by the form but never seeded into the submitted values. It normalizes each property first, like the renderer does. Signed-off-by: cliffhall --- .../web/src/test/core/nullableUnion.test.ts | 33 +++++++ clients/web/src/utils/jsonUtils.test.ts | 76 +++++++++++++++ clients/web/src/utils/jsonUtils.ts | 25 ++++- core/json/nullableUnion.ts | 93 +++++++++++++++---- 4 files changed, 207 insertions(+), 20 deletions(-) diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index f56abbdd..915e7e8e 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -144,6 +144,39 @@ describe("normalizeNullableUnion", () => { }); }); + // JSON Schema's `enum` is untyped, so a typeless branch only implies strings + // when every member is one. Guessing otherwise would hand a number to a + // renderer that declared the option list `string[]`. + it("does not infer string for a typeless non-string enum branch", () => { + const schema = { anyOf: [{ enum: [1, 2] }, { type: "null" as const }] }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("does not infer string for a typeless mixed enum branch", () => { + const schema = { anyOf: [{ enum: ["a", 2] }, { type: "null" as const }] }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("does not infer string for a typeless empty enum branch", () => { + const schema = { anyOf: [{ enum: [] }, { type: "null" as const }] }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + // An explicit `type` is authoritative — the enum members are the server's + // problem at that point, not an inference this function is making. + it("still collapses a non-string enum branch that declares its type", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "number", enum: [1, 2] }, { type: "null" }], + }), + ).toEqual({ + type: "number", + enum: [1, 2], + anyOf: undefined, + nullable: true, + }); + }); + it("infers string for a typeless enum branch", () => { expect( normalizeNullableUnion({ diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 57a61132..4a69409c 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -221,6 +221,42 @@ describe("collectSchemaDefaults", () => { it("returns an empty object when the schema has no properties", () => { expect(collectSchemaDefaults({ type: "object" })).toEqual({}); }); + + // The form normalizes a nullable union before rendering, so this has to as + // well: a nested object's `properties` live on the union's surviving branch, + // and a default only visible there would be displayed but never submitted. + it("collects defaults hoisted out of a nullable object union", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + profile: { + anyOf: [ + { + type: "object", + properties: { nick: { type: "string", default: "ada" } }, + }, + { type: "null" }, + ], + }, + }, + }; + expect(collectSchemaDefaults(schema)).toEqual({ + profile: { nick: "ada" }, + }); + }); + + it("keeps a default declared on the union wrapper itself", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + direction: { + default: "envio", + anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }], + }, + }, + }; + expect(collectSchemaDefaults(schema)).toEqual({ direction: "envio" }); + }); }); describe("hasMissingRequiredFields", () => { @@ -247,4 +283,44 @@ describe("hasMissingRequiredFields", () => { false, ); }); + + // JSON Schema `required` constrains presence, not content — so an explicit + // `null` satisfies a required field whose schema admits null. Since #1928 the + // user can produce exactly that by clearing a nullable enum, and treating it + // as missing would disable submit on a value the schema calls valid. + it("accepts an explicit null for a required field that admits null", () => { + const nullable: InspectorFormSchema = { + type: "object", + properties: { + direction: { + anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }], + }, + }, + required: ["direction"], + }; + expect(hasMissingRequiredFields(nullable, { direction: null })).toBe(false); + expect(hasMissingRequiredFields(nullable, {})).toBe(true); + }); + + it("accepts an explicit null for a required type: [T, null] field", () => { + const nullable: InspectorFormSchema = { + type: "object", + properties: { note: { type: ["string", "null"] } }, + required: ["note"], + }; + expect(hasMissingRequiredFields(nullable, { note: null })).toBe(false); + }); + + it("still rejects null for a required field whose schema is not nullable", () => { + expect(hasMissingRequiredFields(schema, { name: null })).toBe(true); + }); + + it("rejects null for a required field the schema does not describe", () => { + const undescribed: InspectorFormSchema = { + type: "object", + properties: {}, + required: ["ghost"], + }; + expect(hasMissingRequiredFields(undescribed, { ghost: null })).toBe(true); + }); }); diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index 98dd905a..2cdb2331 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -1,3 +1,5 @@ +import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; + export type JsonValue = | string | number @@ -116,7 +118,12 @@ export function collectSchemaDefaults( ): Record { const properties = schema.properties ?? {}; const result: Record = {}; - for (const [fieldName, fieldSchema] of Object.entries(properties)) { + for (const [fieldName, rawSchema] of Object.entries(properties)) { + // Collapse a nullable union first, for the same reason `SchemaForm` does: + // a nested object's `properties` live on the union's surviving branch, so + // without this the form would *display* a hoisted default that never + // reached the seeded values — the field would submit empty (#1928). + const fieldSchema = normalizeNullableUnion(rawSchema); if (fieldSchema.default !== undefined) { result[fieldName] = fieldSchema.default; } else if (fieldSchema.type === "object" && fieldSchema.properties) { @@ -133,15 +140,29 @@ export function collectSchemaDefaults( * Whether any of the schema's required top-level fields is missing a value in * `values` (absent, null, or empty string). Used to gate a form's submit * action until required fields are supplied. + * + * `null` counts as missing only for a field that does not admit it. JSON + * Schema's `required` constrains *presence*, not content, so a required field + * whose schema is a nullable union is satisfied by an explicit `null` — and + * since #1928 the user can produce exactly that by clearing a nullable enum. + * Treating it as missing would leave the form's submit permanently disabled on + * a value the schema calls valid. */ export function hasMissingRequiredFields( schema: InspectorFormSchema, values: Record, ): boolean { const required = schema.required ?? []; + const properties = schema.properties ?? {}; return required.some((field) => { const value = values[field]; - return value === undefined || value === null || value === ""; + if (value === null) { + const fieldSchema = properties[field]; + return fieldSchema === undefined + ? true + : normalizeNullableUnion(fieldSchema).nullable !== true; + } + return value === undefined || value === ""; }); } diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 4e81b791..2adfdd51 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -25,7 +25,8 @@ const RENDERABLE_TYPES = [ "object", ] as const; -type RenderableType = (typeof RENDERABLE_TYPES)[number]; +/** A `type` name the collapse is willing to produce. */ +export type RenderableType = (typeof RENDERABLE_TYPES)[number]; function isRenderableType(type: unknown): type is RenderableType { return RENDERABLE_TYPES.includes(type as RenderableType); @@ -51,6 +52,71 @@ function toBranch(value: unknown): Record | null { return value as Record; } +/** + * Whether a branch's `enum` is one a *typeless* branch can be read as a string + * enum. + * + * JSON Schema's `enum` is untyped — `[1, 2]`, `[true, false]`, and `[null]` are + * all legal — so a bare `{ enum: [...] }` does **not** imply strings. Guessing + * `"string"` for a numeric enum would hand non-strings to a renderer that has + * declared them `string[]`: the web `Select` would receive numbers, and the TUI + * would `String(...)` them and submit `"1"` where the server expects `1`. So + * only an all-string enum earns the inference; anything else stays on the + * fallback path, which renders the value honestly as JSON. + */ +function isStringEnum(value: unknown): boolean { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((member) => typeof member === "string") + ); +} + +/** + * The result of a collapse that actually matched. Spelled out rather than + * reusing `T` because the collapse genuinely changes two of `T`'s fields: + * `type` becomes a single renderable name (never an array, never `"null"`), + * and `anyOf` is cleared. Returning `T` for these would let a caller whose `T` + * types `type` as an array go on treating it as one after it has become a + * string, or dereference an `anyOf` that is now `undefined`. + * + * Note this cannot make the *hoist* sound: an `anyOf` branch is `unknown`, so + * any keyword lifted off it is whatever the server sent, however `T` declares + * it. {@link isStringEnum} validates the one keyword the renderers dereference + * as a typed array (`enum`); the rest reach widgets that read them defensively. + */ +export type NormalizedNullableUnion = Omit< + T, + "type" | "anyOf" +> & { + type?: RenderableType; + anyOf?: undefined; + nullable?: boolean; +}; + +/** + * Build the collapsed schema. + * + * The assertion is needed because the spread merges an unresolved generic `T` + * with an `unknown` branch, and `NormalizedNullableUnion`'s `Omit` stays + * deferred while `T` is open — so TS cannot verify the two line up even though + * every field is set right here. Isolated in this one function so the exported + * signature carries the contract and nothing else has to assert it. + */ +function collapsed( + schema: T, + branch: Record | undefined, + type: RenderableType, +): NormalizedNullableUnion { + return { + ...schema, + ...branch, + type, + anyOf: undefined, + nullable: true, + } as NormalizedNullableUnion; +} + /** * Collapse a nullable union — what Zod's `.nullish()` / `.nullable()` and * FastMCP's optional arguments emit — into `{ type: , nullable: true }`. @@ -76,7 +142,7 @@ function toBranch(value: unknown): Record | null { */ export function normalizeNullableUnion( schema: T, -): T { +): T | NormalizedNullableUnion { if (schema.anyOf?.length === 2) { const branches = schema.anyOf.map(toBranch); const nullBranch = branches.find((entry) => entry?.type === "null"); @@ -85,21 +151,13 @@ export function normalizeNullableUnion( ); if (nullBranch && branch) { - // A bare `{ enum: [...] }` branch carries no `type`; JSON Schema allows - // that, and every value such a schema admits here is a string. - const type = branch.type ?? (branch.enum ? "string" : undefined); + // A branch may carry an `enum` and no `type`; JSON Schema allows that, and + // an all-string enum is unambiguously a string field. See isStringEnum for + // why a non-string enum deliberately does not get the same treatment. + const type = + branch.type ?? (isStringEnum(branch.enum) ? "string" : undefined); if (isRenderableType(type)) { - // The result is `schema` with `type` narrowed to a value it already - // admitted, the branch's keywords merged in, and `nullable` added. TS - // cannot express "a spread of T and a subset of T is still a T", and a - // non-generic return would force every caller to cast instead. - return { - ...schema, - ...branch, - type, - anyOf: undefined, - nullable: true, - } as T; + return collapsed(schema, branch, type); } } } @@ -111,8 +169,7 @@ export function normalizeNullableUnion( ) { const type = schema.type.find((member) => member !== "null"); if (isRenderableType(type)) { - // Same reasoning as above. - return { ...schema, type, nullable: true } as T; + return collapsed(schema, undefined, type); } } From 54c60881149affed72032c52ffa987439abdf098 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 17:30:25 -0400 Subject: [PATCH 16/58] fix: address Copilot review on the list-salvage path (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real defects it caught, plus the tests it asked for: - The strict `tools/list` aggregate is filtered by the SDK for invalid `x-mcp-header` annotations (SEP-2243), but the salvage path was not: one ordinary malformed tool would trip the fallback and readmit every invalid-header tool beside it. Salvaged tools now go through the same scan. - `markResponseRejected` resolves "the last response received for this method", and the re-walk answers that same method — so the mark landed on a salvage page that succeeded while the actually-invalid response rendered clean. The correlation id is now captured before the re-walk. - The report was cleared only on an explicit `disconnect()`. The route out that tears down nothing (`onerror` with no `onclose`) carried it into the next connection, so it is now cleared on the start-clean `resetSessionState()` path as well. Fixing the first exposed two more, both found by the new test: - The modern (2026-07-28) codec validates a result against the spec schema before the caller's, so a lenient `client.request` is rejected exactly as the strict one was — there was no salvage on the modern leg at all. It now goes through the raw-wire channel that exists for the modern `tasks/*` methods. `withModernEnvelope` is split out of `withModernTaskEnvelope` so a list request doesn't claim the tasks extension the user may have disabled. - A malformed tool also blanked the SEP-2243 excluded set, leaving a tool that vanished for an invalid header with no explanation — #1909's own complaint one level down. The scan walk now tolerates a malformed sibling. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall --- .../core/react/useInspectorClient.test.tsx | 70 ++++++++ .../inspectorClient-malformed-list.test.ts | 130 +++++++++++++- core/mcp/inspectorClient.ts | 164 ++++++++++++++++-- 3 files changed, 340 insertions(+), 24 deletions(-) diff --git a/clients/web/src/test/core/react/useInspectorClient.test.tsx b/clients/web/src/test/core/react/useInspectorClient.test.tsx index 56287702..f24e3692 100644 --- a/clients/web/src/test/core/react/useInspectorClient.test.tsx +++ b/clients/web/src/test/core/react/useInspectorClient.test.tsx @@ -144,6 +144,76 @@ describe("useInspectorClient", () => { expect(result.current.excludedTools).toEqual([]); }); + it("subscribes to malformedListItemsChange and updates (#1909)", () => { + const client = new FakeInspectorClient(); + const { result } = renderHook(() => useInspectorClient(client)); + expect(result.current.malformedListItems).toEqual([]); + const malformed = [ + { + method: "resources/templates/list", + index: 1, + label: "array_annotations", + reason: "annotations: Invalid input (expected object)", + }, + ]; + act(() => { + client.setMalformedListItems(malformed); + }); + expect(result.current.malformedListItems).toEqual(malformed); + }); + + it("seeds malformedListItems from the client it is given (#1909)", () => { + // A client that already salvaged before this hook mounted (a screen opened + // after the connect-time list load) must show the report immediately. + const client = new FakeInspectorClient({ status: "connected" }); + const malformed = [ + { method: "tools/list", index: 0, reason: "name: expected string" }, + ]; + client.setMalformedListItems(malformed); + const { result } = renderHook(() => useInspectorClient(client)); + expect(result.current.malformedListItems).toEqual(malformed); + }); + + it("resets malformedListItems to [] when client becomes null (#1909)", () => { + const client = new FakeInspectorClient({ status: "connected" }); + client.setMalformedListItems([ + { method: "tools/list", index: 0, reason: "name: expected string" }, + ]); + const { result, rerender } = renderHook(({ c }) => useInspectorClient(c), { + initialProps: { c: client as FakeInspectorClient | null }, + }); + expect(result.current.malformedListItems.length).toBe(1); + rerender({ c: null }); + expect(result.current.malformedListItems).toEqual([]); + }); + + it("re-seeds malformedListItems on a client swap, not the old server's (#1909)", () => { + const first = new FakeInspectorClient({ status: "connected" }); + first.setMalformedListItems([ + { method: "tools/list", index: 0, reason: "name: expected string" }, + ]); + const second = new FakeInspectorClient({ status: "connected" }); + const { result, rerender } = renderHook(({ c }) => useInspectorClient(c), { + initialProps: { c: first as FakeInspectorClient }, + }); + expect(result.current.malformedListItems).toHaveLength(1); + rerender({ c: second }); + expect(result.current.malformedListItems).toEqual([]); + }); + + it("stops listening for malformedListItemsChange after unmount (#1909)", () => { + const client = new FakeInspectorClient({ status: "connected" }); + const { result, unmount } = renderHook(() => useInspectorClient(client)); + unmount(); + act(() => { + client.setMalformedListItems([ + { method: "tools/list", index: 0, reason: "name: expected string" }, + ]); + }); + // A listener left attached would update state on an unmounted tree. + expect(result.current.malformedListItems).toEqual([]); + }); + it("resets protocolEra / discoverResult to defaults when client becomes null", () => { const client = new FakeInspectorClient({ status: "connected", diff --git a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts index d864e9b1..db04bb13 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts @@ -8,6 +8,7 @@ import { import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { MessageLogState } from "@inspector/core/mcp/state/index.js"; import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; /** * Per-item list salvage against a real connection (#1909). @@ -23,6 +24,8 @@ import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; * real. */ +const MODERN_VERSION = "2026-07-28"; + interface ListPage { items: unknown[]; nextCursor?: string; @@ -37,10 +40,14 @@ function jsonRpcResult(id: unknown, result: unknown) { * methods it was configured with, returning entries verbatim (malformed ones * included). */ -function startMalformedServer(initialPages: { - resourceTemplates?: ListPage[]; - tools?: ListPage[]; -}): Promise<{ +function startMalformedServer( + initialPages: { + resourceTemplates?: ListPage[]; + tools?: ListPage[]; + }, + /** Negotiate the modern era, which is what turns the SEP-2243 gate on. */ + modern = false, +): Promise<{ url: string; stop: () => Promise; calls: string[]; @@ -82,14 +89,41 @@ function startMalformedServer(initialPages: { return; } - const send = (result: unknown) => { + const send = (result: Record) => { res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(jsonRpcResult(body.id, result))); + // The 2026-07-28 codec requires `resultType` on every result; the legacy + // leg ignores it. + res.end( + JSON.stringify( + jsonRpcResult( + body.id, + modern + ? { + resultType: "complete", + // The modern leg also requires the cache hints on a list + // result; `ttlMs: 0` means "don't cache", which keeps each + // call in these tests a real round trip. + ttlMs: 0, + cacheScope: "public", + ...result, + } + : result, + ), + ), + ); }; if (body.method === "initialize") { send({ - protocolVersion: "2025-06-18", + protocolVersion: modern ? MODERN_VERSION : "2025-06-18", + capabilities: { resources: {}, tools: {} }, + serverInfo: { name: "malformed-list-server", version: "1.0.0" }, + }); + return; + } + if (body.method === "server/discover") { + send({ + supportedVersions: [MODERN_VERSION], capabilities: { resources: {}, tools: {} }, serverInfo: { name: "malformed-list-server", version: "1.0.0" }, }); @@ -159,10 +193,13 @@ describe("InspectorClient list salvage (#1909)", () => { let client: InspectorClient | null = null; let stopServer: (() => Promise) | null = null; - async function connectTo(url: string) { + async function connectTo(url: string, era?: "legacy" | "modern") { client = new InspectorClient( { type: "streamable-http", url }, - { environment: { transport: createTransportNode } }, + { + environment: { transport: createTransportNode }, + ...(era ? { versionNegotiation: eraToVersionNegotiation(era) } : {}), + }, ); await client.connect(); return client; @@ -247,6 +284,37 @@ describe("InspectorClient list salvage (#1909)", () => { expect(seen[0]).toMatchObject([{ label: "array_annotations" }]); }); + it("marks the ORIGINAL rejected response, not a salvage page", async () => { + // The re-walk answers the same method again, so a mark taken afterwards + // would land on a page that succeeded and leave the invalid response + // rendering clean — the exact lie #1953 exists to remove. + const server = await startMalformedServer({ + resourceTemplates: [{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE] }], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + const log = new MessageLogState(connected); + + await connected.listAllResourceTemplates(); + + const templateCalls = log + .getMessages() + .filter( + (entry) => + "method" in entry.message && + entry.message.method === "resources/templates/list", + ); + // Two exchanges: the strict aggregate that was rejected, then the re-walk. + expect(templateCalls.length).toBeGreaterThanOrEqual(2); + expect(templateCalls[0]?.clientError).toContain( + "Dropped 1 malformed entry", + ); + for (const later of templateCalls.slice(1)) { + expect(later.clientError).toBeUndefined(); + } + log.destroy(); + }); + it("marks the Protocol entry rejected even though the list rendered", async () => { const server = await startMalformedServer({ resourceTemplates: [{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE] }], @@ -302,6 +370,50 @@ describe("InspectorClient list salvage (#1909)", () => { expect(connected.getMalformedListItems()).toEqual([]); }); + it("does not readmit a SEP-2243-excluded tool while salvaging", async () => { + // The strict aggregate is filtered by the SDK, so the salvage path has to + // reapply the rule: otherwise one ordinary malformed tool anywhere in the + // list quietly readmits every invalid-header tool beside it, turning a + // rendering fix into a spec violation (#1632 + #1909). + const server = await startMalformedServer( + { + tools: [ + { + items: [ + { name: "ok_tool", inputSchema: { type: "object" } }, + // Malformed: trips the salvage fallback. + { name: "broken", inputSchema: "not-a-schema" }, + // Schema-valid, but its x-mcp-header is not an RFC 9110 token. + { + name: "invalid_header_tool", + inputSchema: { + type: "object", + properties: { + value: { + type: "string", + "x-mcp-header": "Bad Header", + }, + }, + }, + }, + ], + }, + ], + }, + true, + ); + stopServer = server.stop; + const connected = await connectTo(server.url, "modern"); + expect(connected.getProtocolEra()).toBe("modern"); + + const { tools } = await connected.listAllTools(); + expect(tools.map((t) => t.name)).toEqual(["ok_tool"]); + // Still reported as excluded rather than silently gone. + expect(connected.getExcludedTools().map((x) => x.tool.name)).toEqual([ + "invalid_header_tool", + ]); + }); + it("rethrows when the rejection is not about any single entry", async () => { // Every entry validates; the result is bad for another reason (a cursor of // the wrong type). There is no per-item story, so the original error must diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index e37994d5..785c0c35 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1616,6 +1616,15 @@ export class InspectorClient extends InspectorClientEventTarget { // (#1953). this.outboundRequestMethods.clear(); this.lastAnsweredRequestByMethod.clear(); + // Per-session for the same reason: a report names entries of the PREVIOUS + // server's list. Cleared here as well as in `disconnect()` because the + // route out that tears down nothing (`onerror` with no `onclose`) would + // otherwise carry it into the next connection — indefinitely, if that + // server never lists the method again (#1909). + if (this.malformedListItems.size > 0) { + this.malformedListItems.clear(); + this.dispatchTypedEvent("malformedListItemsChange", []); + } for (const [, controller] of this.taskInputAbortControllers) { controller.abort(new Error("Connection ended")); } @@ -2372,8 +2381,6 @@ export class InspectorClient extends InspectorClientEventTarget { private withModernTaskEnvelope( params: Record, ): Record { - const existingMeta = - (params._meta as Record | undefined) ?? {}; const clientCapabilities = { ...this.clientCapabilities, // Force-stamp the tasks extension regardless of what the client @@ -2385,11 +2392,31 @@ export class InspectorClient extends InspectorClientEventTarget { [TASKS_EXTENSION_KEY]: {}, }, }; - // Use the NEGOTIATED protocol version so the envelope agrees with the - // `MCP-Protocol-Version` header the transport stamps from the same source — - // a future modern-family revision would negotiate a different string, and - // the two must not disagree. The raw channel only runs on a connected modern - // session, so this is always set; the constant is a defensive fallback. + return this.withModernEnvelope(params, clientCapabilities); + } + + /** + * Stamp the `_meta` envelope every raw-wire request needs on the modern leg: + * the negotiated protocol version, the client identity, and the client + * capabilities. + * + * Split out of {@link withModernTaskEnvelope} so the list-salvage re-walk + * (#1909) can reuse it WITHOUT that method's force-stamped tasks extension — + * a `tools/list` has no business claiming an extension the user may have + * deliberately turned off (#1738). + * + * The NEGOTIATED protocol version is used so the envelope agrees with the + * `MCP-Protocol-Version` header the transport stamps from the same source — a + * future modern-family revision would negotiate a different string, and the + * two must not disagree. The raw channel only runs on a connected modern + * session, so this is always set; the constant is a defensive fallback. + */ + private withModernEnvelope( + params: Record, + clientCapabilities: ClientCapabilities = this.clientCapabilities, + ): Record { + const existingMeta = + (params._meta as Record | undefined) ?? {}; /* v8 ignore start -- fallback only if getProtocolVersion() is unset, which can't happen on the connected modern session this runs on. Bracketed so the ignore is reflow-proof however prettier splits the statement. */ @@ -3154,6 +3181,7 @@ export class InspectorClient extends InspectorClientEventTarget { itemsKey: "tools", itemSchema: ToolSchema, metadata: options?.metadata, + finalize: (salvaged) => this.excludeInvalidXMcpHeaderTools(salvaged), aggregate: async () => [ ...( await this.client!.listTools( @@ -3261,12 +3289,19 @@ export class InspectorClient extends InspectorClientEventTarget { itemSchema, aggregate, metadata, + finalize, }: { method: string; itemsKey: string; itemSchema: z.ZodType; aggregate: () => Promise; metadata?: Record; + /** + * Filter applied to SALVAGED entries only, to reproduce a filter the SDK's + * strict aggregate applies for us. Without it the fallback would return a + * longer list than the strict path — see the `tools/list` caller. + */ + finalize?: (items: T[]) => T[]; }): Promise { try { const items = await this.invokeMcpClient(aggregate, { method }); @@ -3274,10 +3309,35 @@ export class InspectorClient extends InspectorClientEventTarget { return items; } catch (err) { if (!isClientDecodeRejection(err)) throw err; - return this.salvageList({ method, itemsKey, itemSchema, metadata, err }); + const salvaged = await this.salvageList({ + method, + itemsKey, + itemSchema, + metadata, + err, + }); + return finalize ? finalize(salvaged) : salvaged; } } + /** + * Drop the tools the SDK would have excluded from `tools/list` for invalid + * `x-mcp-header` annotations (SEP-2243). + * + * The strict aggregate is filtered by the SDK before we see it, so the + * salvage path has to reapply the same rule itself: otherwise one ordinary + * malformed tool anywhere in the list would trip the fallback and quietly + * readmit every invalid-header tool alongside it — turning a rendering fix + * into a spec violation. The dropped tools are still reported through + * `refreshExcludedTools`, which lists them with their reason. + */ + private excludeInvalidXMcpHeaderTools(tools: Tool[]): Tool[] { + if (!this.excludesInvalidXMcpHeaderTools()) return tools; + return tools.filter( + (tool) => scanXMcpHeaderDeclarations(tool.inputSchema).valid, + ); + } + /** * Re-walk a list method with pagination validated but entries left raw, then * validate each entry on its own — keeping the good ones and reporting the @@ -3310,6 +3370,13 @@ export class InspectorClient extends InspectorClientEventTarget { metadata?: Record; err: unknown; }): Promise { + // Capture the correlation id BEFORE re-walking. `markResponseRejected` + // resolves "the last response received for this method", and the re-walk + // below answers that same method one or more times — so marking afterwards + // would stamp the rejection onto a salvage page that succeeded and leave + // the actually-invalid response rendering as a clean success, which is the + // precise lie #1953 exists to remove. + const rejectedResponseId = this.lastAnsweredRequestByMethod.get(method); const valid: T[] = []; const malformed: MalformedListItem[] = []; const seenCursors = new Set(); @@ -3320,13 +3387,27 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), ...(cursor ? { cursor } : {}), }; + // The modern (2026-07-28) codec validates a result against the SPEC + // schema for the method before the caller's schema is consulted, so a + // lenient `client.request` is rejected exactly as the strict one was — + // there is no salvage without going around it. The raw-wire channel that + // exists for the modern `tasks/*` methods does precisely that: the frame + // goes straight through the transport (still logged for the Protocol / + // Network tabs) and only the caller's schema is applied. Legacy keeps the + // ordinary SDK path, which honors request options and `_meta` for us. const page = await this.invokeMcpClient( () => - this.client!.request( - { method, params }, - LenientListPageSchema, - this.getRequestOptions(metadata?.progressToken), - ), + this.isModernEra() + ? this.rawWireRequest( + method, + this.withModernEnvelope(params), + LenientListPageSchema, + ) + : this.client!.request( + { method, params }, + LenientListPageSchema, + this.getRequestOptions(metadata?.progressToken), + ), { method }, ); const salvaged = salvageListItems({ @@ -3349,7 +3430,12 @@ export class InspectorClient extends InspectorClientEventTarget { // this fallback cannot explain — surface it rather than hide it. if (malformed.length === 0) throw err; - this.markResponseRejected(method, summarizeMalformed(malformed)); + if (rejectedResponseId !== undefined) { + this.dispatchTypedEvent("responseRejected", { + id: rejectedResponseId, + reason: summarizeMalformed(malformed), + }); + } this.setMalformedListItems(method, malformed); this.logger.warn( { method, malformed }, @@ -3382,7 +3468,7 @@ export class InspectorClient extends InspectorClientEventTarget { const seenCursors = new Set(); let cursor: string | undefined; do { - const page = await this.listTools(cursor, metadata); + const page = await this.listToolsForScan(cursor, metadata); for (const tool of page.tools) { const scan = scanXMcpHeaderDeclarations(tool.inputSchema); if (!scan.valid) excluded.push({ tool, reason: scan.reason }); @@ -3400,6 +3486,54 @@ export class InspectorClient extends InspectorClientEventTarget { return excluded; } + /** + * One page of the RAW `tools/list` for the SEP-2243 scan, tolerating a + * malformed sibling entry (#1632 + #1909). + * + * The scan needs the unfiltered list, so it uses the strict single-page call + * — but that call rejects the whole page when any entry is non-conforming, + * which would blank the excluded set and leave a tool that vanished for an + * invalid header with no explanation at all. That is the same failure #1909 + * is about, one level down. So on a decode rejection the page is re-fetched + * leniently and scanned entry by entry; the malformed ones are not "excluded + * tools" and are reported through the salvage channel instead. + */ + private async listToolsForScan( + cursor: string | undefined, + metadata: Record | undefined, + ): Promise<{ tools: Tool[]; nextCursor?: string }> { + try { + return await this.listTools(cursor, metadata); + } catch (err) { + if (!isClientDecodeRejection(err)) throw err; + const effectiveMeta = this.mergeMeta(metadata); + const params = { + ...(effectiveMeta ? { _meta: effectiveMeta } : {}), + ...(cursor ? { cursor } : {}), + }; + const page = this.isModernEra() + ? await this.rawWireRequest( + "tools/list", + this.withModernEnvelope(params), + LenientListPageSchema, + ) + : await this.client!.request( + { method: "tools/list", params }, + LenientListPageSchema, + this.getRequestOptions(metadata?.progressToken), + ); + const { valid } = salvageListItems({ + method: "tools/list", + items: rawItemsOf(page, "tools"), + schema: ToolSchema, + }); + return { + tools: valid, + ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }), + }; + } + } + /** * Call a tool. Caller must provide the Tool (e.g. from a state manager). * @param tool The tool to call (use tool.name for the request) From 2b2e1575111ec8911ccec63a3c916a36ee5b15b5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 17:39:43 -0400 Subject: [PATCH 17/58] fix: gate the connect-time logging/setLevel on the negotiated era (#1990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `logging/setLevel` is a legacy-era method, but connect() called it on any server advertising the `logging` capability, era regardless. The modern wire era rejects it outright, and because the call sits in the post-handshake sequence that rejection failed the *connection* — so the CLI (the one caller passing `initialLoggingLevel`, hardcoded to "debug") could not run any method at all against a modern server that advertised logging. Gate it on `protocolEra !== "modern"`. Modern has no session-scoped level to set: the equivalent is the per-request `io.modelcontextprotocol/logLevel` `_meta` opt-in, already driven by `modernLogLevel` from the server settings. So modern skips the call rather than translating it. Legacy is unchanged. The guard lives next to the capability check in inspectorClient.ts rather than in the CLI, so any future caller passing `initialLoggingLevel` is covered too. Signed-off-by: cliffhall --- ...nspectorClient-initial-logging-era.test.ts | 127 ++++++++++++++++++ core/mcp/inspectorClient.ts | 19 ++- 2 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 clients/web/src/test/core/mcp/inspectorClient-initial-logging-era.test.ts diff --git a/clients/web/src/test/core/mcp/inspectorClient-initial-logging-era.test.ts b/clients/web/src/test/core/mcp/inspectorClient-initial-logging-era.test.ts new file mode 100644 index 00000000..1f5fa165 --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-initial-logging-era.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { + MODERN_PROTOCOL_VERSION, + eraToVersionNegotiation, +} from "@inspector/core/mcp/types.js"; + +/** + * Regression coverage for #1990: the connect-time `initialLoggingLevel` must be + * gated on the negotiated era, not on the server capability alone. + * + * `logging/setLevel` is a legacy-era method, and the modern wire era rejects it + * outright ("Method 'logging/setLevel' is not supported by the negotiated + * protocol version"). Because the call sat in `connect()`'s post-handshake + * sequence, that rejection failed the *connection* — so the CLI (the one caller + * that passes `initialLoggingLevel`, hardcoded to "debug" in + * `clients/cli/src/cli.ts`) could not run *any* method against a modern server + * that advertised `logging`, logging-related or not. + * + * The two cases below are the era split: legacy still sends the request with the + * configured level, modern connects cleanly and never puts the method on the + * wire. The modern half asserts on the sent methods rather than on connect + * succeeding alone — a server that happens to answer the method would hide the + * regression behind a green connect. + * + * A fake transport carries both. The modern leg needs no `initialize` at all: + * the SDK negotiates with a `server/discover` probe and adopts that result's + * capabilities directly, which is also what lets one small class serve both eras + * by answering whichever handshake arrives. + */ +class CapabilityAdvertisingTransport implements Transport { + onmessage?: (message: JSONRPCMessage) => void; + onclose?: () => void; + onerror?: (error: Error) => void; + + /** Every method this client put on the wire, in order. */ + readonly sentMethods: string[] = []; + + /** The params of the `logging/setLevel` request, if one was sent. */ + loggingSetLevelParams?: Record; + + async start(): Promise {} + + async close(): Promise { + this.onclose?.(); + } + + async send(message: JSONRPCMessage): Promise { + if (!("method" in message)) return; + this.sentMethods.push(message.method); + + // The modern era's handshake: one probe, answered with the capabilities the + // client then treats as the server's. + if (message.method === "server/discover" && "id" in message) { + this.reply(message.id, { + supportedVersions: [MODERN_PROTOCOL_VERSION], + capabilities: { logging: {} }, + }); + return; + } + + // The legacy era's handshake. + if (message.method === "initialize" && "id" in message) { + const params = message.params as { protocolVersion: string }; + this.reply(message.id, { + protocolVersion: params.protocolVersion, + capabilities: { logging: {} }, + serverInfo: { name: "logging-server", version: "1.0.0" }, + }); + return; + } + + if (message.method === "logging/setLevel" && "id" in message) { + this.loggingSetLevelParams = message.params as Record; + this.reply(message.id, {}); + return; + } + } + + private reply(id: string | number, result: Record): void { + // The SDK resolves the request from its own microtask; delivering + // synchronously from inside `send()` is the earliest any server could. + this.onmessage?.({ jsonrpc: "2.0", id, result }); + } +} + +async function connectWith( + era: "legacy" | "modern", +): Promise { + const transport = new CapabilityAdvertisingTransport(); + const client = new InspectorClient( + { type: "streamable-http", url: "https://mcp.example/mcp" }, + { + environment: { transport: () => ({ transport }) }, + versionNegotiation: eraToVersionNegotiation(era), + initialLoggingLevel: "debug", + }, + ); + + await client.connect(); + expect(client.getProtocolEra()).toBe(era); + await client.disconnect(); + + return transport; +} + +describe("InspectorClient connect() initial logging level, by era (#1990)", () => { + it("sends logging/setLevel on a legacy connection", async () => { + const transport = await connectWith("legacy"); + + expect(transport.sentMethods).toContain("logging/setLevel"); + expect(transport.loggingSetLevelParams).toMatchObject({ level: "debug" }); + }); + + it("never sends logging/setLevel on a modern connection", async () => { + const transport = await connectWith("modern"); + + // Modern has no session-scoped level: the equivalent is the per-request + // `io.modelcontextprotocol/logLevel` `_meta` opt-in, driven by the + // `modernLogLevel` server setting rather than by `initialLoggingLevel`. + expect(transport.sentMethods).not.toContain("logging/setLevel"); + // The server did advertise logging, so the capability check alone would + // have let the call through — the era gate is what stops it. + expect(transport.sentMethods).toContain("server/discover"); + }); +}); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index b0b809f3..5d8aa86a 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1936,8 +1936,23 @@ export class InspectorClient extends InspectorClientEventTarget { // capabilities and wipe tools/prompts/resources to empty on every connect. await this.fetchServerInfo(); - // Set initial logging level if configured and server supports it - if (this.initialLoggingLevel && this.capabilities?.logging) { + // Set initial logging level if configured and server supports it. + // + // Era-gated (#1990): `logging/setLevel` is a legacy-era method, and the + // modern wire era rejects it outright — so an unconditional call here + // failed the *connect* of every modern server advertising `logging`, + // taking down commands that have nothing to do with logging. Modern has + // no session-scoped level at all: the equivalent is the per-request + // `io.modelcontextprotocol/logLevel` `_meta` opt-in, which + // `modernLogLevel` already drives from the server settings (see + // {@link setModernLogLevel}). So the right behavior on modern is to skip + // this call rather than translate it — `initialLoggingLevel` names a + // mechanism that era does not have. + if ( + this.initialLoggingLevel && + this.capabilities?.logging && + this.protocolEra !== "modern" + ) { await this.client.setLoggingLevel( this.initialLoggingLevel, this.getRequestOptions(), From 80eb321b1436fb324714984e68f228e305b1f9f1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 17:49:09 -0400 Subject: [PATCH 18/58] fix: stop const-less anyOf branches from crashing the form, and never select a non-string enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2007. Two things, both surfaced while answering "does #1928's PR also fix #2007?". **#2007, and why it belongs in this PR.** `z.array(z.union([z.object(…), z.object(…)]))` gives an `items.anyOf` whose branches carry no top-level `const`, so every MultiSelect option was `String(item.const ?? "")` — the empty string, twice. Mantine *throws* on duplicate option values, greying out the whole tool panel. That is a pre-existing bug, but this branch makes it reachable from a new direction: a *nullable* array of object unions previously had no top-level `type` and fell to the JSON fallback, and now collapses to `type: "array"` with `items.anyOf` — straight into the crash. Shipping the collapse without this fix would widen an existing crash, so the two go together. Both shapes are covered by tests. `toConstOptions` now returns `null` unless every branch yields a distinct, non-empty value, and the field falls through to the JSON editor — which is the honest widget for a union of object shapes anyway. Applied to the `string`/`oneOf` path as well, which had the identical `?? ""` and the same duplicate-throw waiting on it. **Copilot round 2.** Round 1's fix kept "an explicit `type` is authoritative", so `{ type: "number", enum: [1, 2] }` still collapses with its enum hoisted. The TUI's dispatcher tests `property.enum` *before* the type switch, and `toSelectOptions` stringifies, so that reached a select that submits `"1"` for `1`. The select path is now gated on `isStringEnum` (exported from core for this), and a typed non-string enum falls through to its typed field: the enum constraint is lost, the value's type is not, which is the safer of the two losses. Pre-existing for a plain numeric enum; the collapse just made the nullable form reachable too. Signed-off-by: cliffhall --- clients/tui/__tests__/schemaToForm.test.ts | 33 ++++++ clients/tui/src/utils/schemaToForm.ts | 16 ++- .../groups/SchemaForm/SchemaForm.test.tsx | 102 ++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 63 +++++++++-- core/json/nullableUnion.ts | 9 +- 5 files changed, 205 insertions(+), 18 deletions(-) diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index 93ca1ca9..769c3026 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -311,6 +311,39 @@ describe("schemaToForm", () => { ]); }); + // ink-form hands a select's stringified value straight back, so a numeric + // enum routed to a select would submit "1" for 1. The typed field loses the + // enum constraint but keeps the value's type — the safer loss. + it("routes a nullable numeric enum to its typed field, not a select", () => { + const form = schemaToForm( + { + properties: { + level: { + anyOf: [{ type: "integer", enum: [1, 2] }, { type: "null" }], + }, + ratio: { + anyOf: [{ type: "number", enum: [0.5, 1.5] }, { type: "null" }], + }, + }, + }, + "numericEnum", + ); + expect( + form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })), + ).toEqual([ + { name: "level", type: "integer" }, + { name: "ratio", type: "float" }, + ]); + }); + + it("routes a plain numeric enum to its typed field too", () => { + const form = schemaToForm( + { properties: { level: { type: "integer", enum: [1, 2] } } }, + "plainNumericEnum", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ type: "integer" }); + }); + it("leaves a union of two real types as a plain string field", () => { const form = schemaToForm( { diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index 5201abbb..6332450c 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -3,7 +3,10 @@ */ import type { FormStructure, FormSection, FormField } from "ink-form"; -import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; +import { + isStringEnum, + normalizeNullableUnion, +} from "@inspector/core/json/nullableUnion.js"; /** Minimal JSON Schema property shape used when building tool parameter forms */ interface JsonSchemaProperty { @@ -105,12 +108,17 @@ export function schemaToForm( ...baseField, options: toSelectOptions(property.items.enum, property.items.enumNames), } as FormField; - } else if (property.enum) { - // Single select + } else if (isStringEnum(property.enum)) { + // Single select. Gated on the members being strings because + // `toSelectOptions` stringifies them and ink-form hands the string + // straight back: a numeric `enum: [1, 2]` would submit `"1"` and violate + // the schema. A typed non-string enum falls through to its typed field + // below, which loses the enum constraint but keeps the value's type — + // the safer of the two losses. field = { type: "select", ...baseField, - options: toSelectOptions(property.enum, property.enumNames), + options: toSelectOptions(property.enum ?? [], property.enumNames), } as FormField; } else { // Map JSON Schema types to ink-form types diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 3ff41b50..e3bc2b3c 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -971,6 +971,108 @@ describe("SchemaForm nullable unions", () => { expect(onChange).toHaveBeenCalledWith({ profile: { nick: "z" } }); }); + // #2007: `z.array(z.union([z.object(…), z.object(…)]))` gives an `items.anyOf` + // whose branches carry no top-level `const`, so every MultiSelect option was + // the empty string — and Mantine *throws* on duplicate option values, greying + // out the whole tool panel. The nullable form below is reachable only because + // this PR now collapses it into `type: "array"`, so it has to be safe too. + it("falls back to the JSON input for an array of object-union items", () => { + const objectBranch = (name: string): InspectorFormSchema => ({ + type: "object", + properties: { type: { type: "string", const: name } }, + required: ["type"], + }); + const schema: InspectorFormSchema = { + type: "object", + properties: { + items: { + title: "Items", + type: "array", + items: { anyOf: [objectBranch("A"), objectBranch("B")] }, + }, + }, + }; + renderWithMantine( + , + ); + expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA"); + }); + + it("falls back to the JSON input for a nullable array of object-union items", () => { + const objectBranch = (name: string): InspectorFormSchema => ({ + type: "object", + properties: { type: { type: "string", const: name } }, + required: ["type"], + }); + const schema: InspectorFormSchema = { + type: "object", + properties: { + items: { + title: "Items", + anyOf: [ + { type: "array", items: { anyOf: [objectBranch("A")] } }, + { type: "null" }, + ], + }, + }, + }; + renderWithMantine( + , + ); + expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA"); + }); + + it("falls back to the JSON input when two anyOf branches share a const", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + items: { + title: "Items", + type: "array", + items: { anyOf: [{ const: "dup" }, { const: "dup" }] }, + }, + }, + }; + renderWithMantine( + , + ); + expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA"); + }); + + it("falls back to a text input for a string oneOf with no consts", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + mode: { + title: "Mode", + type: "string", + oneOf: [{ type: "string" }, { type: "string" }], + }, + }, + }; + renderWithMantine( + , + ); + expect(screen.getByLabelText(/Mode/).tagName).toBe("INPUT"); + }); + + it("still renders a MultiSelect when every anyOf branch has a distinct const", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + items: { + title: "Items", + type: "array", + items: { anyOf: [{ const: "a", title: "Alpha" }, { const: "b" }] }, + }, + }, + }; + renderWithMantine( + , + ); + expect(screen.getByRole("textbox", { name: "Items" })).toBeInTheDocument(); + }); + it("still falls back to the JSON input for a union of two real types", () => { const schema: InspectorFormSchema = { type: "object", diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 628f2ee3..9c203901 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -11,7 +11,10 @@ import { import { useState } from "react"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import { useValueChange } from "../../../hooks/useValueChange"; -import type { InspectorFormSchema } from "../../../utils/jsonUtils"; +import type { + InspectorFormSchema, + JsonSchemaConst, +} from "../../../utils/jsonUtils"; import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; const FieldLabel = Text.withProps({ @@ -52,6 +55,44 @@ function toEnumData( return values; } +/** + * Build `Select`/`MultiSelect` option data from a list of `oneOf`/`anyOf` + * branches that are expected to be constants. + * + * Returns `null` when the branches are not usable as options, which sends the + * field to the JSON fallback instead. Three ways that happens, all of which + * Mantine reacts to badly: + * + * - **No `const`.** An `anyOf` of *object* schemas — what + * `z.array(z.union([z.object(…), z.object(…)]))` compiles to — has no + * top-level `const` on any branch, so every option would be the empty + * string. Mantine **throws** on duplicate option values, which greys out the + * whole tool panel rather than degrading (#2007). + * - **Duplicate values.** Two branches sharing a `const` throw the same way. + * - **An empty option value**, which Mantine cannot render as selectable. + * + * A union of object shapes has no faithful dropdown anyway, so the JSON editor + * is the honest widget for it rather than a lossy or crashing one. + */ +function toConstOptions( + branches: (InspectorFormSchema | JsonSchemaConst)[], +): { value: string; label: string }[] | null { + const options: { value: string; label: string }[] = []; + const seen = new Set(); + for (const branch of branches) { + if (branch.const === undefined || branch.const === null) { + return null; + } + const value = String(branch.const); + if (value === "" || seen.has(value)) { + return null; + } + seen.add(value); + options.push({ value, label: branch.title ?? value }); + } + return options.length > 0 ? options : null; +} + /** * Whether an enum `Select` should offer a clear affordance. Only a schema that * admits `null` does: clearing emits `null`, and an enum without a null branch @@ -248,11 +289,11 @@ export function SchemaForm({ } // string with oneOf - if (fieldSchema.type === "string" && fieldSchema.oneOf) { - const data = fieldSchema.oneOf.map((item) => ({ - value: String(item.const ?? ""), - label: item.title ?? String(item.const ?? ""), - })); + const oneOfData = fieldSchema.oneOf + ? toConstOptions(fieldSchema.oneOf) + : null; + if (fieldSchema.type === "string" && oneOfData) { + const data = oneOfData; return ( { oauthClientId: "", oauthClientSecret: "", oauthScopes: "", + oauthAuthorizationParams: [], enterpriseManaged: true, }); }); + // #2018 — the authorization-parameter rows ride the same onOAuthChange + // callback, so the modal folds them onto the settings object. + it("persists an added authorization-parameter row onto the settings", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "OAuth Settings" })); + await user.click(screen.getByRole("button", { name: "+ Add Parameter" })); + expect(onSettingsChange).toHaveBeenCalledWith( + expect.objectContaining({ + oauthAuthorizationParams: [{ key: "", value: "" }], + }), + ); + }); + it("calls onSettingsChange with a blank row when adding a root", async () => { const user = userEvent.setup(); const onSettingsChange = vi.fn(); diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx index e391c240..869291b1 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx @@ -179,6 +179,10 @@ export function ServerSettingsModal({ oauthClientId: oauth.clientId, oauthClientSecret: oauth.clientSecret, oauthScopes: oauth.scopes, + // #2018: kept as rows (blank ones included) so a half-typed parameter + // survives a re-render; the drop-blank/omit-empty filtering happens on the + // way to disk. An emptied list persists as `[]`, which writes nothing. + oauthAuthorizationParams: oauth.authorizationParams, enterpriseManaged: oauth.enterpriseManaged ? true : undefined, // SEP-2350: persist only the non-default ('throw') so unset servers keep // the SDK's `reauthorize` behavior without writing a spurious field. diff --git a/clients/web/src/test/core/auth/authorizationParams.test.ts b/clients/web/src/test/core/auth/authorizationParams.test.ts new file mode 100644 index 00000000..03f6b0b4 --- /dev/null +++ b/clients/web/src/test/core/auth/authorizationParams.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + RESERVED_AUTHORIZATION_PARAMS, + applyAuthorizationParams, + authorizationParamKeyError, + isReservedAuthorizationParam, +} from "@inspector/core/auth/authorizationParams.js"; + +const AUTHORIZE_URL = + "https://as.example.com/authorize?client_id=abc&response_type=code&state=xyz"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("isReservedAuthorizationParam", () => { + it.each(RESERVED_AUTHORIZATION_PARAMS)("reserves %s", (key) => { + expect(isReservedAuthorizationParam(key)).toBe(true); + }); + + it("ignores case and surrounding whitespace", () => { + expect(isReservedAuthorizationParam(" Client_ID ")).toBe(true); + }); + + it("allows a provider-specific parameter", () => { + expect(isReservedAuthorizationParam("kc_idp_hint")).toBe(false); + }); +}); + +describe("authorizationParamKeyError", () => { + it("returns no error for a blank key (a half-edited row)", () => { + expect(authorizationParamKeyError("")).toBeUndefined(); + expect(authorizationParamKeyError(" ")).toBeUndefined(); + }); + + it("returns no error for an allowed key", () => { + expect(authorizationParamKeyError("login_hint")).toBeUndefined(); + }); + + it("names the offending key for a reserved one", () => { + expect(authorizationParamKeyError(" state ")).toBe( + '"state" is set by the authorization flow and cannot be overridden.', + ); + }); +}); + +describe("applyAuthorizationParams", () => { + it("returns the same URL instance when no params are configured", () => { + const url = new URL(AUTHORIZE_URL); + expect(applyAuthorizationParams(url, undefined)).toBe(url); + }); + + it("returns the same URL instance when the params object is empty", () => { + const url = new URL(AUTHORIZE_URL); + expect(applyAuthorizationParams(url, {})).toBe(url); + }); + + it("appends custom params without mutating the input URL", () => { + const url = new URL(AUTHORIZE_URL); + const merged = applyAuthorizationParams(url, { + kc_idp_hint: "corp-idp", + login_hint: "user@example.com", + }); + + expect(merged).not.toBe(url); + expect(url.searchParams.get("kc_idp_hint")).toBeNull(); + expect(merged.searchParams.get("kc_idp_hint")).toBe("corp-idp"); + expect(merged.searchParams.get("login_hint")).toBe("user@example.com"); + // The SDK-built parameters survive untouched. + expect(merged.searchParams.get("client_id")).toBe("abc"); + expect(merged.searchParams.get("state")).toBe("xyz"); + }); + + it("trims the key and preserves the value verbatim", () => { + const merged = applyAuthorizationParams(new URL(AUTHORIZE_URL), { + " prompt ": " login ", + }); + expect(merged.searchParams.get("prompt")).toBe(" login "); + }); + + it("skips blank keys", () => { + const url = new URL(AUTHORIZE_URL); + const merged = applyAuthorizationParams(url, { " ": "nope" }); + expect(merged).toBe(url); + }); + + it("drops a reserved key with a warning rather than overriding it", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const merged = applyAuthorizationParams(new URL(AUTHORIZE_URL), { + state: "attacker-controlled", + kc_idp_hint: "corp-idp", + }); + + expect(merged.searchParams.get("state")).toBe("xyz"); + expect(merged.searchParams.get("kc_idp_hint")).toBe("corp-idp"); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain("state"); + }); + + it("returns the original URL when every configured key is reserved", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const url = new URL(AUTHORIZE_URL); + expect( + applyAuthorizationParams(url, { + Client_Id: "spoofed", + code_challenge: "spoofed", + }), + ).toBe(url); + expect(warn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/clients/web/src/test/core/auth/ema/transportProvider.test.ts b/clients/web/src/test/core/auth/ema/transportProvider.test.ts index cde4faa7..7cf3acce 100644 --- a/clients/web/src/test/core/auth/ema/transportProvider.test.ts +++ b/clients/web/src/test/core/auth/ema/transportProvider.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { OAuthTokens } from "@modelcontextprotocol/client"; -import type { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js"; +import { + BaseOAuthClientProvider, + CallbackNavigation, + MutableRedirectUrlProvider, +} from "@inspector/core/auth/providers.js"; +import type { OAuthStorage } from "@inspector/core/auth/storage.js"; import type { EmaFlowConfig } from "@inspector/core/auth/ema/emaFlow.js"; import { EmaTransportOAuthProvider } from "@inspector/core/auth/ema/transportProvider.js"; import { @@ -39,6 +44,7 @@ interface FakeInner { tokens: ReturnType; saveTokens: ReturnType; redirectToAuthorization: ReturnType; + redirectToExternalAuthorization: ReturnType; clearCapturedAuthUrl: ReturnType; saveCodeVerifier: ReturnType; codeVerifier: ReturnType; @@ -55,6 +61,7 @@ function createInner(): FakeInner { tokens: vi.fn(), saveTokens: vi.fn(), redirectToAuthorization: vi.fn(), + redirectToExternalAuthorization: vi.fn(), clearCapturedAuthUrl: vi.fn(), saveCodeVerifier: vi.fn(), codeVerifier: vi.fn(() => "verifier-xyz"), @@ -178,6 +185,42 @@ describe("EmaTransportOAuthProvider", () => { expect(startIdpMock).toHaveBeenCalledWith(emaConfig); expect(inner.clearCapturedAuthUrl).toHaveBeenCalledTimes(1); - expect(inner.redirectToAuthorization).toHaveBeenCalledWith(idpUrl); + expect(inner.redirectToExternalAuthorization).toHaveBeenCalledWith(idpUrl); + // The custom-parameter merge lives on `redirectToAuthorization`, so the EMA + // leg must never take that path. + expect(inner.redirectToAuthorization).not.toHaveBeenCalled(); + }); + + // #2018 — the inner provider carries the per-server custom authorization + // parameters, which belong to the MCP server's authorization server. The EMA + // leg sends the user to a *different* authorization server (the enterprise + // IdP), so those parameters must not reach it. Driven against a real + // BaseOAuthClientProvider rather than the fake inner above, since the defect + // is precisely in what the real provider does with the URL it is handed. + it("does not leak custom authorization params onto the IdP authorize URL", async () => { + const navCallback = vi.fn(); + const realInner = new BaseOAuthClientProvider(SERVER_URL, { + storage: { + getScope: vi.fn().mockResolvedValue(undefined), + } as unknown as OAuthStorage, + redirectUrlProvider: new MutableRedirectUrlProvider(), + navigation: new CallbackNavigation(navCallback), + authorizationParams: { kc_idp_hint: "corp-idp", audience: "api://mcp" }, + }); + const emaProvider = new EmaTransportOAuthProvider(realInner, emaConfig); + + const idpUrl = new URL("https://idp.test/authorize?state=abc"); + startIdpMock.mockResolvedValue(idpUrl); + + await emaProvider.redirectToAuthorization( + new URL("https://resource-as.test/authorize"), + ); + + const navigated = navCallback.mock.calls[0]?.[0] as URL; + expect(navigated.searchParams.get("kc_idp_hint")).toBeNull(); + expect(navigated.searchParams.get("audience")).toBeNull(); + expect(navigated.href).toBe(idpUrl.href); + // The captured URL (what the step-up UI shows) must agree. + expect(realInner.getCapturedAuthUrl()?.href).toBe(idpUrl.href); }); }); diff --git a/clients/web/src/test/core/auth/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index 34e59975..67b2f9c7 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -352,6 +352,68 @@ describe("OAuthNavigation", () => { expect(navCallback).toHaveBeenCalledWith(url); }); + // #2018 — the provider is the seam where per-server custom parameters reach + // the authorize URL, since the SDK builds that URL with no hook of its own. + function makeProviderWithParams( + storage: OAuthStorage, + authorizationParams: Record, + navCallback = vi.fn(), + ): BaseOAuthClientProvider { + return new BaseOAuthClientProvider(SERVER, { + storage, + redirectUrlProvider: new MutableRedirectUrlProvider(), + navigation: new CallbackNavigation(navCallback), + authorizationParams, + }); + } + + it("redirectToAuthorization merges configured authorization params", () => { + const storage = makeStorage(); + const navCallback = vi.fn(); + const provider = makeProviderWithParams( + storage, + { kc_idp_hint: "corp-idp" }, + navCallback, + ); + const target = new EventTarget(); + const handler = vi.fn(); + target.addEventListener("oauthAuthorizationRequired", handler); + provider.setEventTarget(target); + + const url = new URL("https://mcp.example.com/authorize?client_id=abc"); + provider.redirectToAuthorization(url); + + const navigated = navCallback.mock.calls[0]?.[0] as URL; + expect(navigated.searchParams.get("kc_idp_hint")).toBe("corp-idp"); + expect(navigated.searchParams.get("client_id")).toBe("abc"); + // The captured URL, the event detail, and the navigation all agree. + expect(provider.getCapturedAuthUrl()?.href).toBe(navigated.href); + const detail = (handler.mock.calls[0]?.[0] as CustomEvent<{ url: URL }>) + .detail; + expect(detail.url.href).toBe(navigated.href); + // The URL the SDK handed in is left untouched. + expect(url.searchParams.get("kc_idp_hint")).toBeNull(); + }); + + it("redirectToAuthorization refuses a reserved authorization param", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const storage = makeStorage(); + const navCallback = vi.fn(); + const provider = makeProviderWithParams( + storage, + { state: "spoofed" }, + navCallback, + ); + + const url = new URL("https://mcp.example.com/authorize?state=real"); + provider.redirectToAuthorization(url); + + const navigated = navCallback.mock.calls[0]?.[0] as URL; + expect(navigated.searchParams.get("state")).toBe("real"); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + it("delegates token and scope persistence to storage", async () => { const storage = makeStorage(); const provider = makeProvider(storage); diff --git a/clients/web/src/test/core/client/runner.test.ts b/clients/web/src/test/core/client/runner.test.ts index 2b3ee568..3936d6c2 100644 --- a/clients/web/src/test/core/client/runner.test.ts +++ b/clients/web/src/test/core/client/runner.test.ts @@ -86,6 +86,44 @@ describe("runner client auth options", () => { expect(opts.oauth?.clientId).toBe("resource-client"); }); + // #2018 — the CLI/TUI leg picks the authorization parameters up from the same + // per-server settings the web leg reads, so all three clients agree. + it("buildRunnerClientAuthOptions forwards custom authorization params", () => { + const settings: InspectorServerSettings = { + oauthAuthorizationParams: [ + { key: "kc_idp_hint", value: "corp" }, + { key: "", value: "dropped" }, + ], + requestTimeout: 0, + connectionTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 10, + autoRefreshOnListChanged: false, + metadata: [], + headers: [], + env: [], + roots: [], + }; + const opts = buildRunnerClientAuthOptions({}, settings); + expect(opts.oauth?.authorizationParams).toEqual({ kc_idp_hint: "corp" }); + }); + + it("buildRunnerClientAuthOptions ignores all-blank authorization param rows", () => { + const settings: InspectorServerSettings = { + oauthAuthorizationParams: [{ key: " ", value: "x" }], + requestTimeout: 0, + connectionTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 10, + autoRefreshOnListChanged: false, + metadata: [], + headers: [], + env: [], + roots: [], + }; + expect(buildRunnerClientAuthOptions({}, settings)).toEqual({}); + }); + it("buildRunnerClientAuthOptions returns no oauth when nothing supplies it", () => { expect(buildRunnerClientAuthOptions({})).toEqual({}); }); diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index c5eee523..824419b5 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -10,6 +10,7 @@ import { mcpConfigToServerEntries, mergeSecretsIntoStored, normalizeServerType, + oauthAuthorizationParamsFromSettings, serverEntriesToMcpConfig, serializeMcpConfig, storedFieldsToInspectorSettings, @@ -924,6 +925,21 @@ describe("extractSecretsFromStored", () => { expect(stripped.oauth).toEqual({ enterpriseManaged: true }); }); + it("preserves oauth.authorizationParams when lifting clientSecret to keychain", () => { + const stored: StoredMCPServer = { + type: "streamable-http", + url: "https://x.test", + oauth: { + clientSecret: "shh", + authorizationParams: { kc_idp_hint: "corp" }, + }, + }; + const { stripped } = extractSecretsFromStored(stored); + expect(stripped.oauth).toEqual({ + authorizationParams: { kc_idp_hint: "corp" }, + }); + }); + it("removes the oauth block entirely when clientSecret was its only property", () => { const stored: StoredMCPServer = { type: "streamable-http", @@ -1188,6 +1204,68 @@ describe("oauthOnInsufficientScope (SEP-2350)", () => { }); expect(stored.oauth?.onInsufficientScope).toBeUndefined(); }); + + // #2018 — custom authorization-request parameters. + it("lifts oauth.authorizationParams into settings rows", () => { + const settings = storedFieldsToInspectorSettings({ + oauth: { authorizationParams: { kc_idp_hint: "corp", prompt: "login" } }, + }); + expect(settings?.oauthAuthorizationParams).toEqual([ + { key: "kc_idp_hint", value: "corp" }, + { key: "prompt", value: "login" }, + ]); + }); + + it("reads an empty authorizationParams object back as unset", () => { + const settings = storedFieldsToInspectorSettings({ + oauth: { clientId: "cid", authorizationParams: {} }, + }); + expect(settings?.oauthAuthorizationParams).toBeUndefined(); + }); + + it("persists authorizationParams under oauth, dropping blank-key rows", () => { + const stored = inspectorSettingsToStoredFields({ + ...baseSettings, + oauthAuthorizationParams: [ + { key: "kc_idp_hint", value: "corp" }, + { key: " ", value: "orphan" }, + ], + }); + expect(stored.oauth?.authorizationParams).toEqual({ kc_idp_hint: "corp" }); + }); + + it("omits the oauth block when every authorization-param row is blank", () => { + const stored = inspectorSettingsToStoredFields({ + ...baseSettings, + oauthAuthorizationParams: [{ key: "", value: "" }], + }); + expect(stored).not.toHaveProperty("oauth"); + }); + + it("omits authorizationParams when the rows are absent", () => { + const stored = inspectorSettingsToStoredFields({ + ...baseSettings, + oauthClientId: "cid", + }); + expect(stored.oauth?.authorizationParams).toBeUndefined(); + }); + + it("derives the client option record, or undefined when nothing survives", () => { + expect( + oauthAuthorizationParamsFromSettings({ + oauthAuthorizationParams: [ + { key: "kc_idp_hint", value: "corp" }, + { key: "", value: "x" }, + ], + }), + ).toEqual({ kc_idp_hint: "corp" }); + expect( + oauthAuthorizationParamsFromSettings({ + oauthAuthorizationParams: [{ key: " ", value: "x" }], + }), + ).toBeUndefined(); + expect(oauthAuthorizationParamsFromSettings({})).toBeUndefined(); + }); }); describe("envRecordToPairs / envPairsToRecord", () => { diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 613d4c6a..9a44f07c 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -701,6 +701,58 @@ describe("server.ts supplemental coverage", () => { await stop(h); } }); + + // #2018 — authorizationParams must be a string record; a hand-edited file + // with anything else drops the whole `oauth` node rather than feeding + // non-string values to the authorize-URL merge. + it("drops oauth whose authorizationParams is not a string record", async () => { + const h = await start({ + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { authorizationParams: { kc_idp_hint: 5 } }, + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`); + expect(res.status).toBe(200); + const body = (await res.json()) as { + mcpServers: Record>; + }; + expect(body.mcpServers.srv).not.toHaveProperty("oauth"); + } finally { + await stop(h); + } + }); + + it("keeps a well-formed authorizationParams record on read", async () => { + const h = await start({ + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { authorizationParams: { kc_idp_hint: "corp" } }, + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`); + const body = (await res.json()) as { + mcpServers: Record>; + }; + expect(body.mcpServers.srv?.oauth).toEqual({ + authorizationParams: { kc_idp_hint: "corp" }, + }); + } finally { + await stop(h); + } + }); }); describe("plaintext-secret migration over a mixed config", () => { diff --git a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts index 2bd71568..9e0f8a5a 100644 --- a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts +++ b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts @@ -449,6 +449,54 @@ describe("/api/servers routes", () => { }); }); + // #2018 — the authorization-parameter rows travel as a pair array on the + // wire and land as a record under `oauth` on disk, mirroring `headers`. + it("persists custom authorization params under oauth on POST", async () => { + const res = await fetch(`${h.baseUrl}/api/servers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "authparams", + config: { type: "streamable-http", url: "https://x.test/mcp" }, + settings: { + headers: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + oauthAuthorizationParams: [ + { key: "kc_idp_hint", value: "corp" }, + { key: "", value: "dropped" }, + ], + }, + }), + }); + expect(res.status).toBe(200); + const stored = readConfig(h.configPath).mcpServers + .authparams as unknown as Record; + expect(stored.oauth).toEqual({ + authorizationParams: { kc_idp_hint: "corp" }, + }); + }); + + it("rejects a malformed oauthAuthorizationParams payload", async () => { + const res = await fetch(`${h.baseUrl}/api/servers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "badauthparams", + config: { type: "streamable-http", url: "https://x.test/mcp" }, + settings: { + headers: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + oauthAuthorizationParams: { kc_idp_hint: "corp" }, + }, + }), + }); + expect(res.status).toBe(400); + }); + it("updates Inspector-extension fields at the top level on PUT", async () => { writeFileSync( h.configPath, diff --git a/core/auth/authorizationParams.ts b/core/auth/authorizationParams.ts new file mode 100644 index 00000000..ee121de8 --- /dev/null +++ b/core/auth/authorizationParams.ts @@ -0,0 +1,105 @@ +/** + * Per-server custom authorization-request parameters (#2018). + * + * Real deployments gate IdP selection, prompt behavior, or audience on a query + * parameter the OAuth/OIDC core specs never standardized — Keycloak's + * `kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's + * `audience`. The Inspector never builds the authorize URL itself (the SDK's + * `auth()` does, and exposes no hook for extra parameters), so the merge + * happens at the one seam that sees the finished URL: + * `BaseOAuthClientProvider.redirectToAuthorization`. + * + * The parameters apply to the **authorization request only** — never the token + * request, which the SDK builds separately and which this module is not wired + * into. They also apply only to the server's **own** authorization server: + * the enterprise-managed (EMA) leg redirects to a different one (the enterprise + * IdP) and goes through `BaseOAuthClientProvider.redirectToExternalAuthorization`, + * which deliberately skips the merge. + */ + +/** + * Parameters the OAuth flow owns and a user may not override. + * + * Overriding any of these does not produce a useful debugging scenario — it + * breaks PKCE (`code_challenge`, `code_challenge_method`), CSRF binding + * (`state`), the callback leg (`redirect_uri`, `response_type`, `client_id`), + * or RFC 8707 resource indicators (`resource`), and the failure surfaces as an + * opaque authorization-server error rather than as an Inspector problem. + * `scope` is excluded too: it has its own field, and the SEP-2350 step-up union + * is computed from that field rather than from these parameters. + */ +export const RESERVED_AUTHORIZATION_PARAMS = [ + "client_id", + "code_challenge", + "code_challenge_method", + "redirect_uri", + "resource", + "response_type", + "scope", + "state", +] as const; + +export type ReservedAuthorizationParam = + (typeof RESERVED_AUTHORIZATION_PARAMS)[number]; + +const RESERVED: ReadonlySet = new Set(RESERVED_AUTHORIZATION_PARAMS); + +/** + * Whether `key` names a parameter the OAuth flow owns. Compared case- and + * whitespace-insensitively: query parameter names are case-sensitive on the + * wire, but a `Client_Id` typed into the form is plainly an attempt to set the + * reserved one, and letting it through would be the silent override this rule + * exists to prevent. + */ +export function isReservedAuthorizationParam(key: string): boolean { + return RESERVED.has(key.trim().toLowerCase()); +} + +/** + * Validation message for one authorization-parameter key, or `undefined` when + * the key is acceptable. A blank key is not an error — the form lets a user + * leave a freshly-added row empty mid-edit, and blank rows are dropped rather + * than rejected. + */ +export function authorizationParamKeyError(key: string): string | undefined { + const trimmed = key.trim(); + if (trimmed === "") return undefined; + if (isReservedAuthorizationParam(trimmed)) { + return `"${trimmed}" is set by the authorization flow and cannot be overridden.`; + } + return undefined; +} + +/** + * Merge custom parameters into a finished authorization URL. + * + * Blank keys are skipped (a half-edited form row) and reserved keys are dropped + * with a warning — never silently, and never overriding what the SDK put there. + * Returns the original URL instance when there is nothing to apply, and + * otherwise a copy, so the caller's URL is never mutated. + */ +export function applyAuthorizationParams( + authorizationUrl: URL, + params: Record | undefined, +): URL { + if (!params) return authorizationUrl; + + const applicable = Object.entries(params).filter(([key]) => { + if (key.trim() === "") return false; + if (isReservedAuthorizationParam(key)) { + console.warn( + `[oauth] Ignoring reserved authorization parameter "${key.trim()}" — it is set by the authorization flow.`, + ); + return false; + } + return true; + }); + + if (applicable.length === 0) return authorizationUrl; + + const merged = new URL(authorizationUrl.href); + for (const [key, value] of applicable) { + merged.searchParams.set(key.trim(), value); + } + return merged; +} diff --git a/core/auth/ema/transportProvider.ts b/core/auth/ema/transportProvider.ts index b130fff9..fc36ea10 100644 --- a/core/auth/ema/transportProvider.ts +++ b/core/auth/ema/transportProvider.ts @@ -84,7 +84,11 @@ export class EmaTransportOAuthProvider implements OAuthClientProvider { async redirectToAuthorization(_authorizationUrl: URL): Promise { const idpAuthorizationUrl = await startEmaIdpAuthorization(this.emaConfig); this.inner.clearCapturedAuthUrl(); - this.inner.redirectToAuthorization(idpAuthorizationUrl); + // The IdP is a different authorization server than the one the inner + // provider was configured for, so the per-server custom authorization + // parameters (#2018) must not be appended here — hence the "external" + // entry point rather than `redirectToAuthorization`. + this.inner.redirectToExternalAuthorization(idpAuthorizationUrl); } saveCodeVerifier(codeVerifier: string): void | Promise { diff --git a/core/auth/index.ts b/core/auth/index.ts index 6086d7ac..53615f8c 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -27,6 +27,15 @@ export { ensureCimdClientRegistration } from "./cimd.js"; export { mcpAuth, type McpAuthOptions, type McpAuthResult } from "./mcpAuth.js"; export { computeScopeUnion, isStrictScopeSuperset } from "./scopes.js"; +// Custom authorization-request parameters (#2018) +export { + RESERVED_AUTHORIZATION_PARAMS, + isReservedAuthorizationParam, + authorizationParamKeyError, + applyAuthorizationParams, +} from "./authorizationParams.js"; +export type { ReservedAuthorizationParam } from "./authorizationParams.js"; + // Storage export type { OAuthStorage, diff --git a/core/auth/providers.ts b/core/auth/providers.ts index 12808542..0c492064 100644 --- a/core/auth/providers.ts +++ b/core/auth/providers.ts @@ -11,6 +11,7 @@ import type { } from "@modelcontextprotocol/client"; import type { OAuthStorage, SaveClientInformationOptions } from "./storage.js"; import { generateOAuthState } from "./utils.js"; +import { applyAuthorizationParams } from "./authorizationParams.js"; /** * Redirect URL provider. Returns the redirect URL for OAuth flows. @@ -96,6 +97,14 @@ export type OAuthProviderConfig = { redirectUrlProvider: RedirectUrlProvider; navigation: OAuthNavigation; clientMetadataUrl?: string; + /** + * Per-server custom authorization-request parameters (#2018), merged into the + * finished authorize URL in {@link BaseOAuthClientProvider.redirectToAuthorization}. + * Reserved (protocol-critical) keys are dropped with a warning — see + * `authorizationParams.ts`. Authorization request only; the token request is + * unaffected. + */ + authorizationParams?: Record; }; /** @@ -115,6 +124,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { protected redirectUrlProvider: RedirectUrlProvider; protected navigation: OAuthNavigation; public clientMetadataUrl?: string; + /** Custom authorization-request parameters (#2018). Authorize URL only. */ + protected authorizationParams?: Record; constructor(serverUrl: string, oauthConfig: OAuthProviderConfig) { this.serverUrl = serverUrl; @@ -122,6 +133,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { this.redirectUrlProvider = oauthConfig.redirectUrlProvider; this.navigation = oauthConfig.navigation; this.clientMetadataUrl = oauthConfig.clientMetadataUrl; + this.authorizationParams = oauthConfig.authorizationParams; } /** @@ -272,6 +284,34 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { } redirectToAuthorization(authorizationUrl: URL): void { + // #2018: the SDK builds the authorize URL and offers no hook for extra + // parameters, so the per-server ones are merged here — the one seam that + // sees the finished URL before navigation. Everything downstream (the + // captured URL used by the step-up modal, the event, the navigation) uses + // the merged URL so all three agree on what was actually requested. + this.performAuthorizationRedirect( + applyAuthorizationParams(authorizationUrl, this.authorizationParams), + ); + } + + /** + * Redirect to an authorize URL belonging to a **different** authorization + * server than the one this provider was configured for, so the per-server + * custom parameters (#2018) MUST NOT be applied. + * + * The only caller is the enterprise-managed (EMA) transport provider, which + * discards the resource AS URL and sends the user to the enterprise IdP + * instead. Parameters a user configured for their MCP server's authorization + * server — a `kc_idp_hint`, an `audience` — are meaningless and potentially + * flow-breaking on the IdP's, and appending them there would leak per-server + * config across an authorization-server boundary. + */ + redirectToExternalAuthorization(authorizationUrl: URL): void { + this.performAuthorizationRedirect(authorizationUrl); + } + + /** Capture, announce, and navigate to a finished authorize URL. */ + private performAuthorizationRedirect(authorizationUrl: URL): void { // Capture URL for return value this.capturedAuthUrl = authorizationUrl; diff --git a/core/client/runner.ts b/core/client/runner.ts index e61a461a..25cd83f3 100644 --- a/core/client/runner.ts +++ b/core/client/runner.ts @@ -11,6 +11,7 @@ import type { InspectorClientOptions, InspectorServerSettings, } from "../mcp/types.js"; +import { oauthAuthorizationParamsFromSettings } from "../mcp/serverList.js"; import { loadClientConfig } from "./config.js"; import type { ClientConfig } from "./types.js"; import { @@ -70,11 +71,16 @@ export function buildRunnerClientAuthOptions( const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig); const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig); + const serverAuthorizationParams = savedSettings + ? oauthAuthorizationParamsFromSettings(savedSettings) + : undefined; + const oauthFromServer = savedSettings && (savedSettings.oauthClientId || savedSettings.oauthClientSecret || savedSettings.oauthScopes || + serverAuthorizationParams || savedSettings.enterpriseManaged) ? { ...(savedSettings.oauthClientId && { @@ -86,6 +92,9 @@ export function buildRunnerClientAuthOptions( ...(savedSettings.oauthScopes && { scope: savedSettings.oauthScopes, }), + ...(serverAuthorizationParams && { + authorizationParams: serverAuthorizationParams, + }), ...(savedSettings.enterpriseManaged && { enterpriseManaged: true, }), diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index b0b809f3..37d9e2cc 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -5421,6 +5421,8 @@ export class InspectorClient extends InspectorClientEventTarget { clientSecret?: string; clientMetadataUrl?: string; scope?: string; + /** Custom authorization-request parameters (#2018). Authorize URL only. */ + authorizationParams?: Record; }): void { if (!this.oauthManager) { throw new Error( diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 3decd17f..f49aff49 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -94,6 +94,7 @@ export class OAuthManager { clientMetadataUrl?: string; scope?: string; enterpriseManaged?: boolean; + authorizationParams?: Record; }): void { this.oauthConfig = { ...this.oauthConfig, @@ -135,6 +136,11 @@ export class OAuthManager { redirectUrlProvider: this.oauthConfig.redirectUrlProvider, navigation: this.oauthConfig.navigation, clientMetadataUrl: this.oauthConfig.clientMetadataUrl, + // #2018: per-server custom authorization-request parameters. Carried on + // the provider (not in OAuth storage like `scope`) — they are pure config + // read from mcp.json, and nothing in the flow needs to persist or union + // them. + authorizationParams: this.oauthConfig.authorizationParams, }); provider.setEventTarget(this.params.getEventTarget()); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 9d765548..2489a521 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -1130,6 +1130,7 @@ export function createRemoteApp( clientId?: string; clientSecret?: string; scopes?: string; + authorizationParams?: Record; enterpriseManaged?: boolean; } => { if (v === null || typeof v !== "object" || Array.isArray(v)) return false; @@ -1137,6 +1138,12 @@ export function createRemoteApp( for (const k of ["clientId", "clientSecret", "scopes"] as const) { if (o[k] !== undefined && typeof o[k] !== "string") return false; } + if ( + o.authorizationParams !== undefined && + !isStringRecord(o.authorizationParams) + ) { + return false; + } if ( o.enterpriseManaged !== undefined && typeof o.enterpriseManaged !== "boolean" @@ -1248,7 +1255,7 @@ export function createRemoteApp( if ("oauth" in valObj && !isOauthObject(valObj.oauth)) { logWarn( { route: "/api/servers", id, droppedKey: "oauth" }, - "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, enterpriseManaged? }`.", + "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, enterpriseManaged? }`.", ); delete valObj.oauth; } @@ -1520,6 +1527,18 @@ export function createRemoteApp( return { ok: false, error: `settings.${optional} must be a string` }; } } + // Optional on the wire (older clients won't send it); when present it must + // be the same `{ key, value }` row shape the headers/metadata lists use. + if ( + obj.oauthAuthorizationParams !== undefined && + !isKvArray(obj.oauthAuthorizationParams) + ) { + return { + ok: false, + error: + "settings.oauthAuthorizationParams must be an array of { key, value }", + }; + } if ( obj.enterpriseManaged !== undefined && typeof obj.enterpriseManaged !== "boolean" @@ -1635,6 +1654,12 @@ export function createRemoteApp( if (typeof obj.oauthScopes === "string" && obj.oauthScopes !== "") { value.oauthScopes = obj.oauthScopes; } + // Carried through with its blank rows intact — the omit-on-empty filtering + // happens on the way to disk (inspectorSettingsToStoredFields), matching + // how the headers/metadata rows are handled. (#2018) + if (isKvArray(obj.oauthAuthorizationParams)) { + value.oauthAuthorizationParams = obj.oauthAuthorizationParams; + } if (obj.enterpriseManaged === true) { value.enterpriseManaged = true; } diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 56aaae5e..0c3c759e 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -164,6 +164,9 @@ type StoredInspectorFields = Pick< * Convert a stored stdio `env` record into the controlled key/value rows the * settings form edits, preserving the object's key insertion order. Empty when * absent. Inverse of `envPairsToRecord`. + * + * The transform is generic key/value, so the OAuth authorization-parameters + * record (#2018) reuses it rather than growing a second copy. */ export function envRecordToPairs( env: Record | undefined, @@ -176,6 +179,9 @@ export function envRecordToPairs( * with an empty/whitespace key (the form lets users leave a new row blank * mid-edit). Inverse of `envRecordToPairs`. Used by the `/api/servers` PUT * write-through that maps `settings.env` back onto `config.env`. + * + * Generic key/value like its inverse, so the OAuth authorization-parameters + * rows (#2018) collapse through it too. */ export function envPairsToRecord( pairs: { key: string; value: string }[], @@ -188,6 +194,21 @@ export function envPairsToRecord( return out; } +/** + * Collapse a server's custom authorization-parameter rows into the record the + * `InspectorClientOptions.oauth.authorizationParams` option takes, or + * `undefined` when nothing survives (no rows, or every row blank-keyed). Shared + * by the web (`App.tsx`) and Node (`buildRunnerClientAuthOptions`) paths so both + * derive the option identically. (#2018) + */ +export function oauthAuthorizationParamsFromSettings( + settings: Pick, +): Record | undefined { + if (!settings.oauthAuthorizationParams) return undefined; + const record = envPairsToRecord(settings.oauthAuthorizationParams); + return Object.keys(record).length > 0 ? record : undefined; +} + /** * Lift the Inspector-extension fields off a freshly-read `StoredMCPServer` * into the pair-array / flat-OAuth `InspectorServerSettings` shape the form @@ -286,6 +307,17 @@ export function storedFieldsToInspectorSettings( if (stored.oauth?.clientSecret) settings.oauthClientSecret = stored.oauth.clientSecret; if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes; + // Optional record with no default; carried through only when the file has a + // non-empty object, so an absent/empty field reads back as unset and the + // write side then omits it — keeping a byte-stable round-trip. (#2018) + if ( + stored.oauth?.authorizationParams && + Object.keys(stored.oauth.authorizationParams).length > 0 + ) { + settings.oauthAuthorizationParams = envRecordToPairs( + stored.oauth.authorizationParams, + ); + } if (stored.oauth?.onInsufficientScope) { settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope; } @@ -395,6 +427,14 @@ export function inspectorSettingsToStoredFields( if (settings.oauthClientSecret) oauthFields.clientSecret = settings.oauthClientSecret; if (settings.oauthScopes) oauthFields.scopes = settings.oauthScopes; + // Blank-key rows (a row the user added but never filled in) are dropped, so + // an all-blank list writes nothing — matching the read side's omit-on-empty. + if (settings.oauthAuthorizationParams) { + const authParams = envPairsToRecord(settings.oauthAuthorizationParams); + if (Object.keys(authParams).length > 0) { + oauthFields.authorizationParams = authParams; + } + } if (settings.oauthOnInsufficientScope) { oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope; } @@ -605,12 +645,17 @@ export function extractSecretsFromStored( const restOauth: { clientId?: string; scopes?: string; + authorizationParams?: Record; enterpriseManaged?: boolean; } = {}; if (stored.oauth.clientId !== undefined) restOauth.clientId = stored.oauth.clientId; if (stored.oauth.scopes !== undefined) restOauth.scopes = stored.oauth.scopes; + // Not a secret — the authorization parameters stay on the stripped entry so + // stripping the client secret doesn't silently discard them. (#2018) + if (stored.oauth.authorizationParams !== undefined) + restOauth.authorizationParams = stored.oauth.authorizationParams; if (stored.oauth.enterpriseManaged === true) { restOauth.enterpriseManaged = true; } diff --git a/core/mcp/types.ts b/core/mcp/types.ts index b89f117b..32273d7a 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -166,6 +166,16 @@ export type StoredMCPServer = MCPServerConfig & { enterpriseManaged?: boolean; /** SEP-2350 step-up policy for `403 insufficient_scope` (default `reauthorize`). */ onInsufficientScope?: OnInsufficientScopePolicy; + /** + * Custom query parameters appended to the OAuth **authorization request** + * (never the token request) — e.g. Keycloak's `kc_idp_hint`, OIDC's + * `login_hint` / `prompt` / `acr_values`, Auth0's `audience`. Reserved, + * protocol-critical keys (`client_id`, `state`, `code_challenge`, …) are + * rejected by the form and dropped with a warning at merge time. Omitted on + * disk when empty, keeping the file diff minimal. Inspector-specific. + * (#2018) + */ + authorizationParams?: Record; }; /** * Filesystem/URI roots advertised to the server via the `roots` client @@ -516,6 +526,14 @@ export interface OAuthSettings { clientId: string; clientSecret: string; scopes: string; + /** + * Custom authorization-request parameters as controlled key/value rows. + * Optional so callers constructing an `OAuthSettings` for the other fields + * don't have to supply an empty array; the form always passes the current + * rows (blank ones included, so a half-typed row survives a re-render). + * (#2018) + */ + authorizationParams?: { key: string; value: string }[]; enterpriseManaged?: boolean; onInsufficientScope?: OnInsufficientScopePolicy; } @@ -674,6 +692,13 @@ export interface InspectorServerSettings { oauthClientId?: string; oauthClientSecret?: string; oauthScopes?: string; + /** + * Custom authorization-request parameters, edited as controlled key/value + * rows (mirrors `headers` / `metadata`). Persisted as the + * `oauth.authorizationParams` record on disk; blank-key rows are dropped on + * the way there. Applied to the authorization request only. (#2018) + */ + oauthAuthorizationParams?: { key: string; value: string }[]; /** * SEP-2350 step-up policy for a `403 insufficient_scope` challenge on this * server's HTTP transport. Defaults to `reauthorize` when unset. @@ -1026,6 +1051,11 @@ export interface InspectorClientOptions { scope?: string; /** Route to EMA flow when true (resource AS creds in clientId/clientSecret). */ enterpriseManaged?: boolean; + /** + * Custom query parameters merged into the authorization request URL only + * (#2018). Reserved, protocol-critical keys are dropped with a warning. + */ + authorizationParams?: Record; }; /** diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 00455a24..1fa58a7a 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -175,7 +175,9 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w | `paginatedLists` | `false` | Fetch tools/resources/prompts one page at a time instead of auto-aggregating | | `advertisedExtensions` | — | Per-extension overrides for what the Inspector declares in `capabilities.extensions` | | `maxFetchRequests` | `1000` | Network-log retention for this server (`DEFAULT_MAX_FETCH_REQUESTS`); `0` means unlimited | -| `oauth` | — | `{ clientId, clientSecret, scopes, enterpriseManaged, onInsufficientScope }` | +| `oauth` | — | `{ clientId, clientSecret, scopes, authorizationParams, enterpriseManaged, onInsufficientScope }` | + +`oauth.authorizationParams` is a string→string record of extra query parameters merged into the OAuth **authorization request** URL only — never the token request. Use it for provider-specific hints the core specs don't standardize (Keycloak's `kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's `audience`). The protocol-critical parameters — `client_id`, `code_challenge`, `code_challenge_method`, `redirect_uri`, `resource`, `response_type`, `scope`, `state` — are **reserved**: the web form rejects them inline, and any that reach the merge anyway are dropped with a warning rather than overriding what the flow set (overriding them breaks PKCE, the CSRF state binding, or RFC 8707). Edit them in Server Settings → Authorization ("Additional authorization parameters"), beside Scopes. A catalog carrying these fields: From 67972adc4eaf369dee5ddd4655d6955ed772cea2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 19:33:27 -0400 Subject: [PATCH 31/58] fix: bound the salvage walks and mark the scan's refused response (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 6. Bound both fallback walks with LIST_MAX_PAGES, which is now also passed to the SDK client as `listMaxPages` so the strict aggregate and its fallback share one value by construction. A server emitting endlessly UNIQUE cursors never trips the repeated-cursor guard, so the salvage re-walk and the SEP-2243 exclusion scan would page and accumulate without bound. Hitting the cap aborts rather than returning a truncated list as a complete one. Mark the exclusion scan's refused response on every fallback-failure path. `salvageList` can rethrow bare because ManagedListState marks from the error it receives; the scan has no such backstop — listAllTools catches and logs a failing scan by design — so an unmarked rethrow left a response the client demonstrably refused rendering as a clean exchange. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E Signed-off-by: cliffhall --- .../web/src/test/core/mcp/listSalvage.test.ts | 19 +++ .../inspectorClient-malformed-list.test.ts | 88 +++++++++++ core/mcp/inspectorClient.ts | 147 ++++++++++++------ core/mcp/listSalvage.ts | 35 +++++ 4 files changed, 245 insertions(+), 44 deletions(-) diff --git a/clients/web/src/test/core/mcp/listSalvage.test.ts b/clients/web/src/test/core/mcp/listSalvage.test.ts index f547eee0..2cdf3f3d 100644 --- a/clients/web/src/test/core/mcp/listSalvage.test.ts +++ b/clients/web/src/test/core/mcp/listSalvage.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect } from "vitest"; import { z } from "zod/v4"; import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import { + LIST_MAX_PAGES, ModernResultEnvelopeSchema, describeIssues, + listPaginationExceeded, isClientDecodeRejection, isSalvageableRejection, labelForRawItem, @@ -317,4 +319,21 @@ describe("summarizeMalformed", () => { expect(summary).toContain("index 0"); expect(summary).toContain("(+1 more)"); }); + + describe("the page bound", () => { + it("matches the cap the SDK client is configured with", () => { + // The value is passed to the SDK as `listMaxPages`, so the strict + // aggregate and the fallback walks are bounded by the same number. + expect(LIST_MAX_PAGES).toBe(64); + }); + + it("names the method and the cap it hit", () => { + // The walk's failure is logged rather than surfaced (the original + // validation error is what the caller sees), so the message is the only + // record of why salvage gave up. + const error = listPaginationExceeded("resources/templates/list"); + expect(error.message).toContain("resources/templates/list"); + expect(error.message).toContain("64 pages"); + }); + }); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts index b9b1f8a6..c9224ff1 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts @@ -9,6 +9,7 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { MessageLogState } from "@inspector/core/mcp/state/index.js"; import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; +import { LIST_MAX_PAGES } from "@inspector/core/mcp/listSalvage.js"; /** * Per-item list salvage against a real connection (#1909). @@ -608,4 +609,91 @@ describe("InspectorClient list salvage (#1909)", () => { await expect(connected.listAllResourceTemplates()).rejects.toThrow(); expect(connected.getMalformedListItems()).toEqual([]); }); + + it("stops the salvage walk at the page cap instead of paging forever", async () => { + // A server whose `nextCursor` never converges but never REPEATS slips the + // repeated-cursor guard: every cursor is fresh, so the walk had no reason + // to stop and would page (and accumulate) without bound. The bound is the + // same `LIST_MAX_PAGES` the SDK aggregate is configured with. + const server = await startMalformedServer({ + resourceTemplates: Array.from( + { length: LIST_MAX_PAGES + 40 }, + (_, i) => ({ + items: [PHP_EMPTY_ANNOTATIONS], + nextCursor: `cursor-${i}`, + }), + ), + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + // Truncation is not salvage: with entries still unfetched, returning what + // we have would present a partial list as complete, so the original + // validation error stands. + await expect(connected.listAllResourceTemplates()).rejects.toThrow(); + expect(connected.getMalformedListItems()).toEqual([]); + + const listCalls = server.calls.filter( + (method) => method === "resources/templates/list", + ); + // One strict aggregate attempt (rejected on page one), then a salvage walk + // bounded at exactly the cap. Without the bound this runs to 104 pages — + // and against a truly endless server, forever. + expect(listCalls).toHaveLength(1 + LIST_MAX_PAGES); + }); + + it("stops the exclusion scan at the page cap too", async () => { + // The SEP-2243 scan walks pages of its own, outside the SDK aggregate's + // cap, so it needs the same bound. Driven directly because a non-converging + // list trips `listAllTools`'s salvage walk first. + const server = await startMalformedServer( + { + tools: Array.from({ length: LIST_MAX_PAGES + 40 }, (_, i) => ({ + items: [{ name: `tool_${i}`, inputSchema: { type: "object" } }], + nextCursor: `cursor-${i}`, + })), + }, + true, + ); + stopServer = server.stop; + const connected = await connectTo(server.url, "modern"); + + await expect(connected.refreshExcludedTools()).rejects.toThrow( + /exceeded 64 pages/, + ); + expect( + server.calls.filter((method) => method === "tools/list"), + ).toHaveLength(LIST_MAX_PAGES); + }); + + it("marks the scan's refused response when its own fallback fails", async () => { + // `tools` is not a list at all, so the scan's lenient re-fetch cannot + // explain the rejection and rethrows. `listAllTools` catches a failing scan + // by design (it must never fail the tools list), so nothing downstream + // would mark the response the client demonstrably refused — it would render + // as a clean exchange. + const server = await startMalformedServer( + { tools: [{ items: "not-a-list" }] }, + true, + ); + stopServer = server.stop; + const connected = await connectTo(server.url, "modern"); + const log = new MessageLogState(connected); + + await expect(connected.refreshExcludedTools()).rejects.toThrow(); + + const marked = log + .getMessages() + .filter( + (entry) => + "method" in entry.message && + entry.message.method === "tools/list" && + entry.clientError !== undefined, + ); + // The mark carries the ORIGINAL decode error: the fallback's own failure is + // a different event from the response being marked. + expect(marked).toHaveLength(1); + expect(marked[0]?.clientError).toBeTruthy(); + log.destroy(); + }); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index cbdba8ba..cc7f0b65 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -172,8 +172,10 @@ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ import { z } from "zod/v4"; import { validateToolOutput } from "./toolOutputValidation.js"; import { + LIST_MAX_PAGES, ModernResultEnvelopeSchema, isSalvageableRejection, + listPaginationExceeded, toolItemSchemaForEra, nextCursorOf, lenientListPageSchema, @@ -688,7 +690,13 @@ export class InspectorClient extends InspectorClientEventTarget { capabilities?: ClientCapabilities; versionNegotiation?: VersionNegotiationOptions; inputRequired?: InputRequiredOptions; + listMaxPages?: number; } = { + // Bound the SDK's auto-aggregate with the SAME constant the #1909 salvage + // re-walks use, so the strict path and its fallback cannot drift apart. + // This is the SDK's own default today; setting it explicitly is what makes + // the shared value a fact rather than a copied guess. + listMaxPages: LIST_MAX_PAGES, // Per-server protocol era (SEP §7.8), threaded from config via // `eraToVersionNegotiation` and defaulted to `{ mode: "legacy" }` in the // constructor. "legacy" keeps the wire byte-identical to a 2025 client; @@ -3442,8 +3450,15 @@ export class InspectorClient extends InspectorClientEventTarget { const malformed: MalformedListItem[] = []; const seenCursors = new Set(); let cursor: string | undefined; + let pages = 0; try { do { + // Bounded like the SDK aggregate this stands in for: a server emitting + // endlessly UNIQUE cursors never trips the repeated-cursor guard below, + // so without this the walk would run forever. Aborting surfaces the + // original validation error rather than a truncated list. + if (pages >= LIST_MAX_PAGES) throw listPaginationExceeded(method); + pages += 1; const effectiveMeta = this.mergeMeta(metadata); const params = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), @@ -3569,7 +3584,14 @@ export class InspectorClient extends InspectorClientEventTarget { if (this.excludesInvalidXMcpHeaderTools()) { const seenCursors = new Set(); let cursor: string | undefined; + let pages = 0; do { + // Same bound as `salvageList`, for the same reason: this walk is also + // outside the SDK aggregate's `listMaxPages` cap. Throwing (rather than + // committing a partial set) keeps the caller's "the excluded list may + // be incomplete" warning the honest description of what happened. + if (pages >= LIST_MAX_PAGES) throw listPaginationExceeded("tools/list"); + pages += 1; const page = await this.listToolsForScan(cursor, metadata); malformed.push( ...page.malformed.map((entry) => ({ @@ -3641,53 +3663,90 @@ export class InspectorClient extends InspectorClientEventTarget { // client refused the result. const rejectedResponseId = this.lastAnsweredRequestByMethod.get("tools/list"); - const effectiveMeta = this.mergeMeta(metadata); - const params = { - ...(effectiveMeta ? { _meta: effectiveMeta } : {}), - // See `salvageList`: "" is a valid cursor. - ...(cursor !== undefined && { cursor }), - }; - const pageSchema = lenientListPageSchema(ListToolsResultSchema, "tools"); - const page = this.isModernEra() - ? await this.rawWireRequest( - "tools/list", - this.withModernEnvelope(params), - pageSchema, - ) - : await this.client!.request( - { method: "tools/list", params }, - pageSchema, - this.getRequestOptions(metadata?.progressToken), + try { + const effectiveMeta = this.mergeMeta(metadata); + const params = { + ...(effectiveMeta ? { _meta: effectiveMeta } : {}), + // See `salvageList`: "" is a valid cursor. + ...(cursor !== undefined && { cursor }), + }; + const pageSchema = lenientListPageSchema( + ListToolsResultSchema, + "tools", + ); + const page = this.isModernEra() + ? await this.rawWireRequest( + "tools/list", + this.withModernEnvelope(params), + pageSchema, + ) + : await this.client!.request( + { method: "tools/list", params }, + pageSchema, + this.getRequestOptions(metadata?.progressToken), + ); + // Same era-envelope check as `salvageList` — the raw-wire path bypasses + // the codec that would otherwise enforce it. + if ( + this.isModernEra() && + !ModernResultEnvelopeSchema.safeParse(page).success + ) { + throw err; + } + const items = rawItemsOf(page, "tools"); + // Not a list at all — a top-level violation the per-item pass can't + // explain, so the strict error stands (mirrors `salvageList`). + if (items === undefined) throw err; + const { valid, malformed } = salvageListItems({ + method: "tools/list", + items, + schema: toolItemSchemaForEra(this.isModernEra()), + }); + if (rejectedResponseId !== undefined && malformed.length > 0) { + this.dispatchTypedEvent("responseRejected", { + id: rejectedResponseId, + reason: summarizeMalformed(malformed), + }); + } + const nextCursor = nextCursorOf(page); + return { + tools: valid, + ...(nextCursor !== undefined && { nextCursor }), + malformed, + }; + } catch (fallbackErr) { + // The fallback could not explain the rejection — the re-fetch itself + // failed, or the page carried a violation that is not per-item (a bad + // modern envelope, a `tools` that is not a list). + // + // `salvageList` can simply rethrow here, because ITS caller + // (`ManagedListState.refresh`) marks the Protocol entry from the error + // it receives. This walk has no such backstop: `listAllTools` catches + // and LOGS a failing scan so it can never fail the tools list, so an + // unmarked rethrow leaves the response the client demonstrably refused + // (`isSalvageableRejection` above proves it was an `InvalidResult`) + // rendering as a clean exchange — the exact lie #1953 removes and the + // one this method's own capture above exists to prevent. So mark it + // here, with the ORIGINAL decode error as the reason: the fallback's + // own failure is a different event, and the response being marked is + // the one `err` was raised for. + if (rejectedResponseId !== undefined) { + this.dispatchTypedEvent("responseRejected", { + id: rejectedResponseId, + reason: err instanceof Error ? err.message : String(err), + }); + } + if (fallbackErr !== err) { + this.logger.warn( + { err: fallbackErr }, + "Excluded-tools salvage re-fetch failed; surfacing the original list error", ); - // Same era-envelope check as `salvageList` — the raw-wire path bypasses - // the codec that would otherwise enforce it. - if ( - this.isModernEra() && - !ModernResultEnvelopeSchema.safeParse(page).success - ) { + } + // Restore the correlation the re-fetch moved, so a later mark for this + // method does not land on a lenient page that succeeded. + this.restoreResponseCorrelation("tools/list", rejectedResponseId); throw err; } - const items = rawItemsOf(page, "tools"); - // Not a list at all — a top-level violation the per-item pass can't - // explain, so the strict error stands (mirrors `salvageList`). - if (items === undefined) throw err; - const { valid, malformed } = salvageListItems({ - method: "tools/list", - items, - schema: toolItemSchemaForEra(this.isModernEra()), - }); - if (rejectedResponseId !== undefined && malformed.length > 0) { - this.dispatchTypedEvent("responseRejected", { - id: rejectedResponseId, - reason: summarizeMalformed(malformed), - }); - } - const nextCursor = nextCursorOf(page); - return { - tools: valid, - ...(nextCursor !== undefined && { nextCursor }), - malformed, - }; } } diff --git a/core/mcp/listSalvage.ts b/core/mcp/listSalvage.ts index 7c5ab1e4..f043cf60 100644 --- a/core/mcp/listSalvage.ts +++ b/core/mcp/listSalvage.ts @@ -46,6 +46,41 @@ export interface MalformedListItem { reason: string; } +/** + * Hard cap on the pages a salvage / scan re-walk will fetch. + * + * The SDK's auto-aggregating `list*()` is capped by + * `ClientOptions.listMaxPages` and THROWS when it is hit, so a partial + * aggregate is never presented as a complete one. The fallback walks in this + * module go around that aggregate, so without their own bound a server whose + * `nextCursor` never converges — but never repeats, so the non-converging guard + * never fires — would keep the walk issuing requests and accumulating entries + * forever. That is reachable from any server the user points the Inspector at, + * which is the whole population here. + * + * The value is passed to the SDK client as `listMaxPages` (see + * `InspectorClient`'s constructor) rather than merely matching its default, so + * the strict path and the fallback are bounded by the SAME number by + * construction and cannot drift if the SDK changes its default. + */ +export const LIST_MAX_PAGES = 64; + +/** + * The error a walk raises when it hits {@link LIST_MAX_PAGES}. + * + * Both callers turn this into "the strict error stands": hitting the cap means + * there are more entries we did not fetch, so returning what we have would + * present a TRUNCATED list as a complete one — the same silent-truncation + * failure {@link rawItemsOf} refuses for a non-array page. That is why this + * aborts rather than `break`ing like the repeated-cursor guard, which stops on + * a page whose entries were all seen already and so loses nothing. + */ +export function listPaginationExceeded(method: string): Error { + return new Error( + `Salvage walk for ${method} exceeded ${LIST_MAX_PAGES} pages without the server's pagination converging`, + ); +} + /** * Whether a failed fetch is the CLIENT refusing a response it received, rather * than the request never producing one. From 2fdbd1905f6d6619bfd5ad805fcf2a88c62d2537 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 19:44:09 -0400 Subject: [PATCH 32/58] fix: make the collapse and null-admission fail closed on unevaluated schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 8 opened the same conjunctive-semantics seam a third time, so this stops patching cases and inverts the defaults instead. Both guards now fail CLOSED: anything this module cannot evaluate sends the field to the JSON editor rather than to a widget built from a partial reading. 1. The wrapper-conflict check enumerated the *validation* keywords and declined when two of them disagreed — which meant any keyword nobody listed (`oneOf`, `allOf`, `not`, `$ref`, a future vocabulary) was silently treated as safe to drop. That whitelist fails open, and open means rendering a widget for a constraint that is not the schema's. It is replaced by an allowlist of *annotation* keywords: the collapse declines whenever the wrapper carries anything beyond annotations and the union itself. Stricter than the old equality test — a wrapper restating its branch's `type` now declines too — which costs a dropdown on a rare redundant shape and buys a rule with no gap in it. 2. `admitsNull` returned true for shapes it had not actually evaluated: `not: { type: "null" }` rules null out, `allOf` can add a member that does, `oneOf` requires *exactly one* branch to match (so a null branch does not by itself mean null validates), and a branch like `{ type: "null", const: "x" }` is unsatisfiable. All now answer false. Under-claiming nullability costs a clear button; over-claiming lets the form emit a value the schema rejects, so the asymmetry decides it. Note the third finding — draft validity never reaching the submit controls — is NOT fixed here, and it is worse than I described last round: `ToolDetailPanel` gates Execute on `isExecuting` alone and never calls `hasMissingRequiredFields`, so my earlier claim that required fields were already covered was wrong for the Tools tab. Corrected on #2020, which is where the fix belongs. Signed-off-by: cliffhall --- .../web/src/test/core/nullableUnion.test.ts | 77 +++++++--- core/json/nullableUnion.ts | 133 ++++++++++-------- 2 files changed, 132 insertions(+), 78 deletions(-) diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index a2127f2b..3a59953a 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -418,21 +418,24 @@ describe("normalizeNullableUnion", () => { expect(normalizeNullableUnion(schema)).toBe(schema); }); - // Identical values are no conflict, so the ordinary redundant-type shape - // still collapses rather than being punished for being explicit. - it("collapses when wrapper and branch agree on a shared keyword", () => { - expect( - normalizeNullableUnion({ - type: "string", - anyOf: [{ type: "string", enum: ["a"] }, { type: "null" }], - }), - ).toEqual({ + // Deliberately stricter than value-equality: a wrapper restating its branch's + // `type` also declines. That costs a dropdown on a rare redundant shape and + // buys a rule with no gap, which is the better trade for a module that cannot + // evaluate JSON Schema. + it("declines even when wrapper and branch agree on a shared keyword", () => { + const schema = { type: "string", - enum: ["a"], - anyOf: undefined, - // The explicit non-null `type` rules null out, conjunctively. - nullable: false, - }); + anyOf: [{ type: "string", enum: ["a"] }, { type: "null" as const }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("declines when the wrapper carries an applicator the module cannot read", () => { + const schema = { + allOf: [{ minLength: 2 }], + anyOf: [{ type: "string" }, { type: "null" as const }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); }); it("treats wrapper metadata as safe to keep alongside branch constraints", () => { @@ -484,13 +487,51 @@ describe("admitsNull", () => { expect(admitsNull(wide)).toBe(true); }); - it("recognizes a null branch in a oneOf, and a nested type array", () => { - expect(admitsNull({ oneOf: [{ type: "string" }, { type: "null" }] })).toBe( - true, - ); + it("recognizes a null branch declared as a nested type array", () => { expect(admitsNull({ anyOf: [{ type: ["string", "null"] }] })).toBe(true); }); + // Applicators this module does not evaluate. `oneOf` requires *exactly one* + // branch to match, so a null branch does not by itself mean null validates; + // `not` and `allOf` can rule it out outright. Under-claiming costs a clear + // button, over-claiming lets the form emit a value the schema rejects. + it("declines to claim nullability for applicators it cannot evaluate", () => { + expect(admitsNull({ oneOf: [{ type: "string" }, { type: "null" }] })).toBe( + false, + ); + expect( + admitsNull({ not: { type: "null" }, anyOf: [{ type: "null" }] }), + ).toBe(false); + expect( + admitsNull({ allOf: [{ type: "string" }], anyOf: [{ type: "null" }] }), + ).toBe(false); + }); + + // A branch can name null and still admit nothing. + it("ignores an unsatisfiable null branch", () => { + expect( + admitsNull({ anyOf: [{ type: "string" }, { type: "null", const: "x" }] }), + ).toBe(false); + expect( + admitsNull({ + anyOf: [{ type: "string" }, { type: "null", enum: ["x"] }], + }), + ).toBe(false); + expect( + admitsNull({ + anyOf: [{ type: "string" }, { type: "null", not: { type: "null" } }], + }), + ).toBe(false); + }); + + it("still accepts a plain null branch alongside an unsatisfiable one", () => { + expect( + admitsNull({ + anyOf: [{ type: "null", const: "x" }, { type: "null" }], + }), + ).toBe(true); + }); + it("is false for a schema that does not permit null", () => { expect(admitsNull({ type: "string" })).toBe(false); expect(admitsNull({ type: ["string", "number"] })).toBe(false); diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index c24fec92..9f79af3d 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -45,6 +45,8 @@ export interface NullableUnionSchema { // Read only by `admitsNull`, which considers encodings the collapse itself // declines to flatten, and the sibling constraints that can rule null out. oneOf?: readonly unknown[]; + allOf?: readonly unknown[]; + not?: unknown; nullable?: boolean; const?: unknown; } @@ -127,69 +129,63 @@ function nullExcludedBySiblings(schema: NullableUnionSchema): boolean { } /** - * Keywords that *constrain what values are valid*, as opposed to describing the - * field (`title`, `description`, `default`, `enumNames`). + * Keywords that only *describe* a field. Everything else constrains what values + * are valid. * - * Only these can conflict in a way that matters: a wrapper and its branch each - * carrying one means the value must satisfy **both**, which the hoist cannot - * express. Metadata is safe to merge because nothing validates against it. + * This is an **allowlist on purpose**, and the direction matters. The previous + * version enumerated the *validation* keywords and declined on a conflict among + * them, which meant any keyword nobody thought of — `oneOf`, `allOf`, `not`, + * `$ref`, a future vocabulary — was silently treated as safe to drop. A missing + * entry there fails open, and open means rendering a widget for a constraint + * that is not the schema's. Listing the harmless keys instead fails closed: an + * unrecognized keyword sends the field to the JSON editor, which is the correct + * answer for a schema this module does not understand. */ -const VALIDATION_KEYWORDS = [ - "type", - "enum", - "const", - "items", - "properties", - "required", - "additionalProperties", - "minimum", - "maximum", - "exclusiveMinimum", - "exclusiveMaximum", - "multipleOf", - "minLength", - "maxLength", - "pattern", - "format", - "minItems", - "maxItems", - "uniqueItems", -] as const; +const ANNOTATION_KEYWORDS = new Set([ + "title", + "description", + "default", + "enumNames", + "examples", + "deprecated", + "readOnly", + "writeOnly", + "$comment", + "$schema", + "$id", +]); /** - * Whether hoisting this branch would *drop* one of the wrapper's constraints. + * Whether hoisting a branch would *drop* something the wrapper constrains. * * The hoist is a spread, so a branch key replaces the wrapper's — but JSON * Schema applies sibling keywords **conjunctively**, and a replacement is not a * conjunction. A wrapper `enum: ["a"]` around a branch `enum: ["a", "b"]` means * `"a"` (both must hold), yet the spread yields `["a", "b"]` and the rendered * dropdown would offer — and submit — a `"b"` the schema rejects. `type`, - * bounds, and the object keywords fail the same way. + * bounds, the object keywords, and the applicators (`oneOf`, `allOf`, `not`) + * all fail the same way. * - * Rather than implement intersection for every keyword (a much larger surface, - * and one where a subtly wrong intersection is worse than none), the collapse - * **declines** when wrapper and branch disagree, and the field renders through - * the JSON editor with its full schema intact. Identical values are not a - * conflict, so the common `{ type: "string", anyOf: [{ type: "string", … }] }` - * still collapses. + * Rather than intersect them — a much larger surface, and one where a subtly + * wrong intersection is worse than none because it renders a *plausible* widget + * for the wrong constraint — the collapse **declines** whenever the wrapper + * carries anything beyond annotations and the union itself. The field then + * renders through the JSON editor with its full schema intact. * - * Comparison is by canonical JSON, so two structurally equal objects written - * with different key order read as a conflict. That is conservative in the safe - * direction: the field falls back rather than mis-renders. + * This is stricter than comparing values for equality: a wrapper that merely + * restates its branch's `type` now also declines. That costs a dropdown on a + * rare redundant shape and buys a rule with no gap in it, which is the better + * trade for a module that cannot evaluate JSON Schema. */ -function branchDropsWrapperConstraint( - schema: NullableUnionSchema, - branch: Record, -): boolean { - const wrapper = schema as Record; - return VALIDATION_KEYWORDS.some((keyword) => { - const wrapperValue = wrapper[keyword]; - const branchValue = branch[keyword]; - if (wrapperValue === undefined || branchValue === undefined) { - return false; - } - return JSON.stringify(wrapperValue) !== JSON.stringify(branchValue); - }); +function wrapperCarriesConstraints(schema: NullableUnionSchema): boolean { + return Object.keys(schema).some( + (key) => key !== "anyOf" && !ANNOTATION_KEYWORDS.has(key), + ); +} + +/** Whether a schema's `type` names `"null"` at all. */ +function typeNamesNull(type: unknown): boolean { + return Array.isArray(type) ? type.includes("null") : type === "null"; } /** @@ -313,6 +309,19 @@ export function admitsNull(schema: NullableUnionSchema): boolean { if (nullExcludedBySiblings(schema)) { return false; } + // Applicators this module does not evaluate. `not: { type: "null" }` rules + // null out; `allOf` can add a member that does; and `oneOf` requires + // **exactly one** branch to match, so a null branch does not by itself mean + // `null` validates — two matching branches make it fail. Rather than answer + // these half-correctly, say no: under-claiming nullability costs a clear + // button, while over-claiming lets the form emit a value the schema rejects. + if ( + schema.not !== undefined || + schema.allOf !== undefined || + schema.oneOf !== undefined + ) { + return false; + } if (schema.nullable === true) { return true; } @@ -322,21 +331,25 @@ export function admitsNull(schema: NullableUnionSchema): boolean { // as the union. Falling through to the branch scan here would let the union // override a constraint that outranks it. if (schema.type !== undefined) { - return Array.isArray(schema.type) - ? schema.type.includes("null") - : schema.type === "null"; + return typeNamesNull(schema.type); } - return [schema.anyOf, schema.oneOf].some((branches) => - branches?.some((entry) => { + return ( + schema.anyOf?.some((entry) => { const branch = toBranch(entry); - if (branch === null) { + if (branch === null || !typeNamesNull(branch.type)) { return false; } + // A branch that names null can still admit nothing: `{ type: "null", + // const: "x" }` is unsatisfiable, and its own applicators are as opaque + // here as the wrapper's. + const branchSchema = branch as NullableUnionSchema; return ( - branch.type === "null" || - (Array.isArray(branch.type) && branch.type.includes("null")) + !nullExcludedBySiblings(branchSchema) && + branchSchema.not === undefined && + branchSchema.allOf === undefined && + branchSchema.oneOf === undefined ); - }), + }) ?? false ); } @@ -375,7 +388,7 @@ export function normalizeNullableUnion( // Hoisting would silently drop a wrapper constraint the branch also // carries, so decline instead; see `branchDropsWrapperConstraint`. - if (nullBranch && branch && !branchDropsWrapperConstraint(schema, branch)) { + if (nullBranch && branch && !wrapperCarriesConstraints(schema)) { // A branch may carry an `enum` and no `type`; JSON Schema allows that, and // an all-string enum is unambiguously a string field. See isStringEnum for // why a non-string enum deliberately does not get the same treatment. From dbda70795bbdc66f4ec6064ccaec8350fd5642d3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 19:52:50 -0400 Subject: [PATCH 33/58] fix: make the salvage item contract match each era in both directions (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 7. The era contract was derived from the neutral ToolSchema plus one override, which diverged from the real wire schemas both ways. Measured against SDK 2.0.0 on a real connection: - modern accepts `inputSchema.properties: 5` and `required: [1]`; ours rejected them, so a malformed sibling made salvage DROP a conforming tool and report it as malformed — a false accusation from the screen whose job is to say which entry is wrong. - legacy rejects `outputSchema.properties: 5` / `required: [1]`; ours accepted them, readmitting entries the strict path refused. - modern rejects a non-string `inputSchema.$schema`, legacy accepts it; ours accepted on both. Each era is now expressed as its own wire schema, bridged to Tool by `eraToolItemSchema`. The bridge exists because both eras accept values the exported Tool type cannot describe (the SDK returns them typed as Tool anyway), so the wire schema's output type is irrelevant and z.custom supplies the caller's type — which is what avoids an `as unknown as` here. Forwarded issues keep their path so the report still names the field. The full measured matrix is pinned table-driven in listSalvage-era.test.ts: each row asserts the SDK's verdict AND that our contract agrees, so a divergence that closes or widens fails loudly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E Signed-off-by: cliffhall --- .../integration/mcp/listSalvage-era.test.ts | 131 ++++++++++++++- core/mcp/listSalvage.ts | 155 ++++++++++++++---- 2 files changed, 256 insertions(+), 30 deletions(-) diff --git a/clients/web/src/test/integration/mcp/listSalvage-era.test.ts b/clients/web/src/test/integration/mcp/listSalvage-era.test.ts index 723b47da..e90af022 100644 --- a/clients/web/src/test/integration/mcp/listSalvage-era.test.ts +++ b/clients/web/src/test/integration/mcp/listSalvage-era.test.ts @@ -9,7 +9,10 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; import { ToolSchema } from "@modelcontextprotocol/core"; -import { toolItemSchemaForEra } from "@inspector/core/mcp/listSalvage.js"; +import { + describeIssues, + toolItemSchemaForEra, +} from "@inspector/core/mcp/listSalvage.js"; /** * The era item contract the salvage fallback validates tools against (#1909). @@ -179,6 +182,21 @@ describe("salvage era item contract (#1909)", () => { expect(neutral.success && neutral.data).toHaveProperty("execution"); }); + it("names the offending field, not just 'invalid tool'", () => { + // The era contract validates through a wire schema and forwards its issues, + // so the PATH has to survive that hop — it is the whole diagnostic value of + // the report, and a collapsed "Invalid input" would still pass every + // accept/reject assertion above. + const parsed = toolItemSchemaForEra(false).safeParse({ + name: "t", + inputSchema: { type: "array" }, + }); + expect(parsed.success).toBe(false); + expect(parsed.success || describeIssues(parsed.error)).toMatch( + /^inputSchema\.type: .*object/, + ); + }); + it("the era contract matches the strict path on both divergences", () => { // Legacy: rejected, exactly as the strict parse above rejected it. expect( @@ -190,6 +208,117 @@ describe("salvage era item contract (#1909)", () => { expect(modern.success && modern.data).not.toHaveProperty("execution"); }); + /** + * What the STRICT path did with a tool, decided without consulting our own + * contract: the SDK accepted it only if the tool came back and nothing was + * reported malformed. (If the SDK rejects and our contract accepts, salvage + * finds nothing per-item wrong and rethrows — also a "reject" observation.) + */ + async function strictVerdict( + tool: unknown, + era: "legacy" | "modern", + ): Promise<"accept" | "reject"> { + const server = await startToolsServer([tool], era === "modern"); + stop = server.stop; + const connected = await connect(server.url, era); + try { + const { tools } = await connected.listAllTools(); + return tools.length === 1 && + connected.getMalformedListItems().length === 0 + ? "accept" + : "reject"; + } catch { + return "reject"; + } + } + + const withInput = (inputSchema: unknown) => ({ name: "t", inputSchema }); + const withOutput = (outputSchema: unknown) => ({ + name: "t", + inputSchema: { type: "object" }, + outputSchema, + }); + + /** + * Every measured divergence between the two eras and the neutral schema. + * + * Each row is checked twice: that the SDK still behaves this way, and that + * our replicated contract agrees with it. The two directions both matter — + * an `accept` we reject means salvage would drop a tool the strict path kept + * and report a conforming server as malformed; a `reject` we accept means + * salvage readmits an entry the era refused. + */ + const ERA_CASES: { + label: string; + tool: unknown; + legacy: "accept" | "reject"; + modern: "accept" | "reject"; + }[] = [ + { + label: "inputSchema.type: array", + tool: withInput({ type: "array" }), + legacy: "reject", + modern: "reject", + }, + { + label: "inputSchema.properties: 5", + tool: withInput({ type: "object", properties: 5 }), + legacy: "reject", + modern: "accept", + }, + { + label: "inputSchema.required: [1]", + tool: withInput({ type: "object", required: [1] }), + legacy: "reject", + modern: "accept", + }, + { + label: "inputSchema.$schema: 5", + tool: withInput({ type: "object", $schema: 5 }), + legacy: "accept", + modern: "reject", + }, + { + label: "outputSchema.type: array", + tool: withOutput({ type: "array" }), + legacy: "reject", + modern: "accept", + }, + { + label: "outputSchema.properties: 5", + tool: withOutput({ type: "object", properties: 5 }), + legacy: "reject", + modern: "accept", + }, + { + label: "outputSchema.required: [1]", + tool: withOutput({ type: "object", required: [1] }), + legacy: "reject", + modern: "accept", + }, + { + label: "outputSchema.$schema: 5", + tool: withOutput({ type: "object", $schema: 5 }), + legacy: "accept", + modern: "reject", + }, + ]; + + for (const era of ["legacy", "modern"] as const) { + for (const { label, tool, ...expected } of ERA_CASES) { + it(`${era}: ${label} — contract agrees with the strict path`, async () => { + const observed = await strictVerdict(tool, era); + // Pins the SDK: if this fails, the era rule changed and the replicated + // contract below needs revisiting rather than the test relaxing. + expect(observed).toBe(expected[era]); + // Pins our agreement with it, in whichever direction it differs. + expect( + toolItemSchemaForEra(era === "modern").safeParse(tool).success, + ).toBe(expected[era] === "accept"); + }); + } + } + it("keeps only era-valid tools when salvaging beside a malformed one", async () => { // The end-to-end shape of the defect: a malformed entry trips the // fallback, and the array-output tool must NOT ride along on the strength diff --git a/core/mcp/listSalvage.ts b/core/mcp/listSalvage.ts index f043cf60..1f2109c3 100644 --- a/core/mcp/listSalvage.ts +++ b/core/mcp/listSalvage.ts @@ -229,11 +229,23 @@ export function describeIssues(error: z.ZodError): string { /* v8 ignore next -- zod never produces a failed parse with zero issues; guards against an empty message if it ever did */ if (!issue) return "did not match the expected shape"; const path = issue.path.join("."); - const detail = expectedDetail(issue); - const message = detail ? `${issue.message} (${detail})` : issue.message; + const message = describeIssue(issue); return path.length > 0 ? `${path}: ${message}` : message; } +/** + * One issue's message, with the expected type folded in when it adds something. + * + * Split out from {@link describeIssues} because {@link eraToolItemSchema} has to + * apply it EARLY: forwarding another schema's issues re-emits them as `custom` + * ones, which carry a message and a path but no `expected` field, so the detail + * has to be baked into the message while the original issue is still in hand. + */ +function describeIssue(issue: z.core.$ZodIssue): string { + const detail = expectedDetail(issue); + return detail ? `${issue.message} (${detail})` : issue.message; +} + /** * `expected object` — appended because the issue's own message cannot be relied * on to carry it. zod's browser build reports a bare "Invalid input" where Node @@ -305,36 +317,121 @@ export function summarizeMalformed(malformed: MalformedListItem[]): string { /** * The item contract for `tools/list` on a given era. * - * The public `ToolSchema` is era-NEUTRAL, and the negotiated era's wire schema - * is stricter in ways the SDK does not export. Validating candidates against - * the neutral schema alone would let the fallback keep an entry the strict path - * refused — an entry the user would then see, and could call, on a connection - * whose era says it isn't valid. The two known divergences (verified against - * SDK 2.0.0 by `listSalvage-era.test.ts`, which fails if either changes): + * The public `ToolSchema` is era-NEUTRAL, and each negotiated era's wire schema + * differs from it — in BOTH directions, which is the thing to keep in mind + * here. The obvious failure is a fallback that is more permissive than the + * strict path, readmitting an entry the era refused. The equally real one is a + * fallback that is more STRICT: it drops a tool the strict path would have + * kept, and this module then reports it as malformed — accusing a conforming + * server of an error it did not make, on a screen whose whole job is to say + * which entry is wrong. * - * - **legacy (2025)** requires `outputSchema.type === "object"`; the neutral - * schema accepts any object, so a tool with `outputSchema: { type: "array" }` - * would survive salvage while the strict parse rejects the whole page. - * - **modern (2026)** deletes `execution`, and the strict path STRIPS it. Left - * in place, a salvaged tool would carry a field the era doesn't have, and the - * UI would render capability the connection can't honor. + * Every rule below was measured against SDK 2.0.0 on a real connection rather + * than read off the (unexported) era schemas, and each is pinned by + * `listSalvage-era.test.ts` so a divergence that closes or widens fails loudly + * instead of rotting: + * + * | case | 2025 (legacy) | 2026 (modern) | + * | --------------------------- | ------------- | ------------- | + * | `inputSchema.type: "array"` | reject | reject | + * | `inputSchema.properties: 5` | reject | **accept** | + * | `inputSchema.required: [1]` | reject | **accept** | + * | `inputSchema.$schema: 5` | **accept** | reject | + * | `outputSchema.type: "array"`| reject | **accept** | + * | `outputSchema.properties: 5`| reject | **accept** | + * | `outputSchema.required: [1]`| reject | **accept** | + * | `outputSchema.$schema: 5` | **accept** | reject | + * | `execution` | kept | **stripped** | + * + * Which reduces to two overrides on the neutral schema, one per era, plus the + * modern `execution` strip — see {@link LegacyToolWireSchema} and + * {@link ModernToolWireSchema}. * * Replicating rules that live in the SDK's internals is a real cost, taken - * deliberately: the alternative is a fallback that is quietly more permissive - * than the path it stands in for. The tests pin each rule to observed SDK - * behavior, so a divergence that closes (or widens) fails loudly rather than - * rotting. + * deliberately: the alternative is a fallback that quietly disagrees with the + * path it stands in for, in whichever direction the neutral schema happens to + * differ. */ export function toolItemSchemaForEra(isModern: boolean): z.ZodType { - if (isModern) { - // `execution` is not part of the 2026 wire shape; drop it as the codec does. - return ToolSchema.transform((tool) => { - if (tool.execution === undefined) return tool; - const { execution: _execution, ...rest } = tool; - return rest; - }); - } - return ToolSchema.extend({ - outputSchema: z.looseObject({ type: z.literal("object") }).optional(), - }); + return isModern + ? eraToolItemSchema(ModernToolWireSchema, stripExecution) + : eraToolItemSchema(LegacyToolWireSchema, (tool) => tool); +} + +/** + * The 2025 wire shape for a tool. + * + * The only divergence from the neutral schema is `outputSchema`, which the + * legacy codec validates with the SAME shape it uses for `inputSchema` — a + * required `type: "object"`, `properties` as a JSON record, `required` as + * strings — where the neutral schema checks only `$schema`. Reusing + * `ToolSchema.shape.inputSchema` states that relationship instead of + * re-deriving it, so the two cannot drift apart by hand. + */ +const LegacyToolWireSchema = ToolSchema.extend({ + outputSchema: ToolSchema.shape.inputSchema.optional(), +}); + +/** + * The 2026-07-28 wire shape for a tool. + * + * Its `inputSchema` is the mirror image of the legacy divergence: the modern + * codec constrains only `type` and `$schema` and leaves the remaining JSON + * Schema keywords open, where the neutral schema additionally validates + * `properties` and `required`. `outputSchema` needs no override — the neutral + * `$schema`-only check is already what the modern codec applies. + */ +const ModernToolWireSchema = ToolSchema.extend({ + inputSchema: z.looseObject({ + type: z.literal("object"), + $schema: z.string().optional(), + }), +}); + +/** `execution` is not part of the 2026 wire shape; drop it as the codec does. */ +function stripExecution(tool: Tool): Tool { + if (tool.execution === undefined) return tool; + const { execution: _execution, ...rest } = tool; + return rest; +} + +/** + * Bridge a per-era WIRE schema to the `Tool` the rest of the client works with. + * + * The two are deliberately separate, because on BOTH eras the wire schema + * accepts values the exported `Tool` type cannot describe — `properties: 5` is + * a valid modern `inputSchema` keyword set, and the SDK hands exactly that back + * typed as a `Tool`. So the era rules are expressed as an ordinary schema whose + * output type is irrelevant (it is only ever consulted for a verdict), and + * `z.custom` supplies the type the caller needs. That is what avoids the + * `as unknown as` this would otherwise require — and it is the same assertion + * the SDK itself makes, in the same place. + * + * The wire schema's issues are copied across one by one, each keeping its PATH, + * rather than collapsed into a single "invalid tool" — so `describeIssues` can + * still name the offending field (`inputSchema.type`) instead of reporting a + * bare "Invalid input", which is the whole point of the report this module + * produces. They are re-emitted as `custom` issues (the only code that can + * carry an arbitrary forwarded message), which is why the expected-type detail + * is folded into the message here by {@link describeIssue} rather than left for + * `describeIssues` to recover from a field the new issue will not have. + */ +function eraToolItemSchema( + wire: z.ZodType, + normalize: (tool: Tool) => Tool, +): z.ZodType { + return z + .custom() + .superRefine((value, ctx) => { + const parsed = wire.safeParse(value); + if (parsed.success) return; + for (const issue of parsed.error.issues) { + ctx.addIssue({ + code: "custom", + path: issue.path, + message: describeIssue(issue), + }); + } + }) + .transform(normalize); } From 274a328abef59744878e15b3d04aad7c3b5bc412 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 19:58:07 -0400 Subject: [PATCH 34/58] fix: apply the fail-closed rule uniformly, and correct the admitsNull docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 9: four findings, all of them places where round 8's fail-closed inversion was the right idea applied incompletely. The rule was only enforced at the positions I happened to be editing; it now holds everywhere a schema is inspected. 1. `admitsNull` returned early on an explicit `type`, ignoring a sibling `anyOf` — but the two are conjunctive, so `{ type: ["string", "null"], anyOf: [{ type: "string" }] }` admits no null at all. A sibling union now withholds the claim. 2. A null-typed branch could be unsatisfiable via its own nested `anyOf` (`{ type: "null", anyOf: [{ type: "string" }] }`). The branch check tested `not`/`allOf`/`oneOf` but not `anyOf`; it now treats a nested union as equally opaque. 3. The `type: [T, "null"]` collapse path had no applicator guard at all — `collapsed` clears `anyOf` unconditionally and the renderers ignore the rest, so `{ type: ["string", "null"], anyOf: [{ const: "a" }] }` was widened into an unconstrained string field. That path now declines the same way the `anyOf` path does. 4. The `admitsNull` docstring still advertised `oneOf` support that round 8 deliberately removed. Rewritten to state what is recognized, what is declined and why, and the asymmetry that settles the direction. The three code fixes share one helper (`hasOpaqueApplicator`) rather than repeating the condition, so the next position that needs it cannot drift. Signed-off-by: cliffhall --- .../web/src/test/core/nullableUnion.test.ts | 49 ++++++++++++-- core/json/nullableUnion.ts | 66 +++++++++++++++---- 2 files changed, 97 insertions(+), 18 deletions(-) diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index 3a59953a..525dae29 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -357,6 +357,26 @@ describe("normalizeNullableUnion", () => { }); }); + // The type-array path keeps the schema's own keywords, but `collapsed` clears + // `anyOf` and the renderers ignore the other applicators — so a compound + // schema would be widened into an unconstrained field rather than collapsed. + it("declines a type-array nullable that also carries a sibling anyOf", () => { + const schema = { + type: ["string", "null"], + anyOf: [{ const: "a" }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("declines a type-array nullable that carries an opaque applicator", () => { + const withNot = { type: ["string", "null"], not: { const: "a" } }; + expect(normalizeNullableUnion(withNot)).toBe(withNot); + const withOneOf = { type: ["string", "null"], oneOf: [{ const: "a" }] }; + expect(normalizeNullableUnion(withOneOf)).toBe(withOneOf); + const withAllOf = { type: ["string", "null"], allOf: [{ minLength: 2 }] }; + expect(normalizeNullableUnion(withAllOf)).toBe(withAllOf); + }); + it("normalizes type array array|null", () => { expect(normalizeNullableUnion({ type: ["array", "null"] })).toEqual({ type: "array", @@ -524,6 +544,17 @@ describe("admitsNull", () => { ).toBe(false); }); + it("ignores a null branch made unsatisfiable by its own nested anyOf", () => { + expect( + admitsNull({ + anyOf: [ + { type: "string" }, + { type: "null", anyOf: [{ type: "string" }] }, + ], + }), + ).toBe(false); + }); + it("still accepts a plain null branch alongside an unsatisfiable one", () => { expect( admitsNull({ @@ -588,14 +619,20 @@ describe("admitsNull", () => { }); it("still honors an explicit type that does admit null", () => { + expect(admitsNull({ type: ["string", "null"] })).toBe(true); + expect(admitsNull({ type: "null" })).toBe(true); + }); + + // A sibling union is conjunctive with the `type`, so it can reject null even + // when the type list names it. It is not evaluated here, so its mere presence + // withholds the claim. + it("withholds the claim when a sibling anyOf sits beside an explicit type", () => { expect( - admitsNull({ - type: ["string", "null"], - anyOf: [{ type: "string" }, { type: "null" }], - }), - ).toBe(true); + admitsNull({ type: ["string", "null"], anyOf: [{ type: "string" }] }), + ).toBe(false); + // Unsatisfiable: names null, admits nothing. expect(admitsNull({ type: "null", anyOf: [{ type: "string" }] })).toBe( - true, + false, ); }); diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 9f79af3d..2e4db037 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -188,6 +188,23 @@ function typeNamesNull(type: unknown): boolean { return Array.isArray(type) ? type.includes("null") : type === "null"; } +/** + * Applicators this module does not evaluate, and which are **conjunctive** with + * everything beside them — so any of them can rule `null` out, or constrain a + * value in a way no widget here would honor. + * + * `anyOf` is excluded because it is the keyword being *recognized*: a caller + * that has already identified the nullable-union shape checks it directly. Every + * other position treats a stray `anyOf` as opaque too. + */ +function hasOpaqueApplicator(schema: NullableUnionSchema): boolean { + return ( + schema.not !== undefined || + schema.allOf !== undefined || + schema.oneOf !== undefined + ); +} + /** * What {@link stripNullEnumMembers} concluded about an `enum`. * @@ -301,9 +318,24 @@ function collapsed( * other would make a form reject a value its own schema accepts, which is why * these are two functions rather than a `.nullable` flag read off the collapse. * - * Recognizes every encoding the collapse does, plus the ones it declines: - * `nullable: true`, `type: "null"`, `type: [..., "null"]`, and a `"null"` - * branch anywhere in an `anyOf` or `oneOf` of any size. + * **Answers `false` for anything it cannot fully evaluate**, which is a larger + * set than it may look. Recognized: `nullable: true`, `type: "null"`, + * `type: [..., "null"]`, and a `"null"` branch in an `anyOf` of any size. + * Deliberately *not* recognized, despite naming null: + * + * - **`oneOf`** — it requires *exactly one* branch to match, so a `null` branch + * does not mean `null` validates; two matching branches make it fail. + * - **`not` / `allOf`**, at this level or on the branch, which can rule null out. + * - **A sibling `anyOf` beside an explicit `type`**, since the two are + * conjunctive and the union is not evaluated here. + * - **A branch that names null but is unsatisfiable**, whether by its own + * `enum`/`const` or its own nested applicators. + * + * The asymmetry is what settles the direction: under-claiming costs a clear + * button and treats a valid `null` as missing, while over-claiming lets the + * form emit a value the schema rejects. So callers get a firm "no" rather than + * an optimistic guess, and a schema this module cannot read renders through the + * JSON editor with its constraints intact. */ export function admitsNull(schema: NullableUnionSchema): boolean { if (nullExcludedBySiblings(schema)) { @@ -315,11 +347,7 @@ export function admitsNull(schema: NullableUnionSchema): boolean { // `null` validates — two matching branches make it fail. Rather than answer // these half-correctly, say no: under-claiming nullability costs a clear // button, while over-claiming lets the form emit a value the schema rejects. - if ( - schema.not !== undefined || - schema.allOf !== undefined || - schema.oneOf !== undefined - ) { + if (hasOpaqueApplicator(schema)) { return false; } if (schema.nullable === true) { @@ -331,6 +359,13 @@ export function admitsNull(schema: NullableUnionSchema): boolean { // as the union. Falling through to the branch scan here would let the union // override a constraint that outranks it. if (schema.type !== undefined) { + // A sibling `anyOf` is conjunctive with the `type`, so it can reject null + // even when the type list names it — `{ type: ["string", "null"], anyOf: + // [{ type: "string" }] }` admits no null at all. Not evaluated here, so + // its mere presence withholds the claim. + if (schema.anyOf !== undefined) { + return false; + } return typeNamesNull(schema.type); } return ( @@ -345,9 +380,10 @@ export function admitsNull(schema: NullableUnionSchema): boolean { const branchSchema = branch as NullableUnionSchema; return ( !nullExcludedBySiblings(branchSchema) && - branchSchema.not === undefined && - branchSchema.allOf === undefined && - branchSchema.oneOf === undefined + !hasOpaqueApplicator(branchSchema) && + // A nested union inside the branch is as opaque as the others: `{ type: + // "null", anyOf: [{ type: "string" }] }` names null and admits nothing. + branchSchema.anyOf === undefined ); }) ?? false ); @@ -408,7 +444,13 @@ export function normalizeNullableUnion( if ( Array.isArray(schema.type) && schema.type.length === 2 && - schema.type.includes("null") + schema.type.includes("null") && + // This path keeps the schema's own keywords, but `collapsed` clears `anyOf` + // and the renderers ignore the other applicators — so a compound schema + // like `{ type: ["string", "null"], anyOf: [{ const: "a" }] }` would be + // widened into an unconstrained string field. Leave it for the JSON editor. + schema.anyOf === undefined && + !hasOpaqueApplicator(schema) ) { const type = schema.type.find((member) => member !== "null"); if (isRenderableType(type)) { From 7db807b0de1d2cf0e340455c77bda13b5b48e6cb Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 20:08:03 -0400 Subject: [PATCH 35/58] fix: return the era-parsed tool, and hide aggregate reports in paginated mode (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 8. The era contract validated with the wire schema but returned the raw input, so the wire schema's normalization was discarded — both era schemas are z.object, which strips unknown top-level fields as the strict codec does, so salvaged tools could carry fields the strict path drops. The parsed output is now what gets normalized and returned. This also simplifies the bridge: issues come straight from the wire parse with their paths intact, so the custom-issue forwarding (and its early describeIssue application) is gone. The malformed-entry report is written by the aggregate walk's salvage, but in paginated mode the tools/prompts/resources panels render the paged stores, which neither write nor clear it — leaving a stale warning above a freshly fetched page it does not describe, with indices into an aggregate the user is no longer looking at. Those reports are now suppressed in paginated mode; resource templates keep theirs, having no paged store. Suppressing rather than adding per-page salvage: pointing at the wrong entry costs more than staying quiet, and per-page salvage is a feature, not a display fix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E Signed-off-by: cliffhall --- clients/web/src/App.tsx | 11 +++- .../integration/mcp/listSalvage-era.test.ts | 23 +++++++ .../web/src/utils/malformedListReport.test.ts | 44 +++++++++++++ clients/web/src/utils/malformedListReport.ts | 46 +++++++++++++ core/mcp/listSalvage.ts | 65 ++++++------------- 5 files changed, 144 insertions(+), 45 deletions(-) create mode 100644 clients/web/src/utils/malformedListReport.test.ts create mode 100644 clients/web/src/utils/malformedListReport.ts diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index b42a2e3b..09645d9a 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -163,6 +163,7 @@ import { import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile"; import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs"; import { enrichProtocolEntries } from "./utils/correlateTransportErrors"; +import { visibleMalformedListItems } from "./utils/malformedListReport"; import { parseDeepLink, deepLinkConfigEquals, @@ -1056,6 +1057,14 @@ function App() { setPaginatedListsOverride(null); }, [persistedPaginatedLists, activeServerId]); const paginatedLists = paginatedListsOverride ?? persistedPaginatedLists; + // The malformed-entry report is written by the aggregate walk's salvage. In + // paginated mode the tools/prompts/resources panels render the paged stores + // instead, which never write or clear it — so it would linger above a page it + // does not describe. See `visibleMalformedListItems` (#1909 + #1721). + const shownMalformedListItems = useMemo( + () => visibleMalformedListItems(malformedListItems, paginatedLists), + [malformedListItems, paginatedLists], + ); const connected = connectionStatus === "connected"; const toolsPagination = usePaginatedList({ connected, @@ -4377,7 +4386,7 @@ function App() { latencyMs={latencyMs} tools={tools} excludedTools={excludedTools} - malformedListItems={malformedListItems} + malformedListItems={shownMalformedListItems} prompts={prompts} resources={resources} resourceTemplates={resourceTemplates} diff --git a/clients/web/src/test/integration/mcp/listSalvage-era.test.ts b/clients/web/src/test/integration/mcp/listSalvage-era.test.ts index e90af022..3b570bc2 100644 --- a/clients/web/src/test/integration/mcp/listSalvage-era.test.ts +++ b/clients/web/src/test/integration/mcp/listSalvage-era.test.ts @@ -197,6 +197,29 @@ describe("salvage era item contract (#1909)", () => { ); }); + it("strips unknown fields the way the strict codec does", async () => { + // The contract validates through a wire schema; returning the ORIGINAL + // value rather than the parsed one would leave salvaged tools carrying + // fields the strict path drops, so the two paths would hand the UI + // differently-shaped tools for the same server. + const strayTool = { + name: "stray", + inputSchema: { type: "object" }, + notAToolField: "should not survive", + }; + const parsed = toolItemSchemaForEra(false).safeParse(strayTool); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data).not.toHaveProperty("notAToolField"); + + // And the strict path agrees — this is the shape being matched, not an + // invented rule. + const server = await startToolsServer([strayTool], false); + stop = server.stop; + const connected = await connect(server.url, "legacy"); + const { tools } = await connected.listAllTools(); + expect(tools[0]).not.toHaveProperty("notAToolField"); + }); + it("the era contract matches the strict path on both divergences", () => { // Legacy: rejected, exactly as the strict parse above rejected it. expect( diff --git a/clients/web/src/utils/malformedListReport.test.ts b/clients/web/src/utils/malformedListReport.test.ts new file mode 100644 index 00000000..66fd24fb --- /dev/null +++ b/clients/web/src/utils/malformedListReport.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import type { MalformedListItem } from "@inspector/core/mcp"; +import { visibleMalformedListItems } from "./malformedListReport"; + +const entry = (method: string): MalformedListItem => ({ + method, + index: 0, + reason: "annotations: expected object", +}); + +const TOOLS = entry("tools/list"); +const PROMPTS = entry("prompts/list"); +const RESOURCES = entry("resources/list"); +const TEMPLATES = entry("resources/templates/list"); + +describe("visibleMalformedListItems", () => { + it("shows every report in aggregate mode", () => { + const items = [TOOLS, PROMPTS, RESOURCES, TEMPLATES]; + expect(visibleMalformedListItems(items, false)).toEqual(items); + }); + + it("returns the same array in aggregate mode rather than a copy", () => { + // Identity is load-bearing: the value feeds a memo, and a fresh array every + // render would re-render the panels for a filter that removed nothing. + const items = [TOOLS]; + expect(visibleMalformedListItems(items, false)).toBe(items); + }); + + it("suppresses the paged lists' reports in paginated mode", () => { + // Those panels render the paged store, which neither writes nor clears this + // report — so the entry would describe a list that is no longer on screen. + expect( + visibleMalformedListItems([TOOLS, PROMPTS, RESOURCES], true), + ).toEqual([]); + }); + + it("keeps resource templates, which are aggregate-backed in both modes", () => { + // Templates have no paged store, so their report always describes the list + // actually being rendered. + expect( + visibleMalformedListItems([TOOLS, TEMPLATES, PROMPTS], true), + ).toEqual([TEMPLATES]); + }); +}); diff --git a/clients/web/src/utils/malformedListReport.ts b/clients/web/src/utils/malformedListReport.ts new file mode 100644 index 00000000..5b19b0ee --- /dev/null +++ b/clients/web/src/utils/malformedListReport.ts @@ -0,0 +1,46 @@ +import type { MalformedListItem } from "@inspector/core/mcp"; + +/** + * Which malformed-entry reports the UI may show, given the pagination mode + * (#1909 + #1721). + * + * The report is produced by the salvage fallback inside the client's `listAll*` + * aggregate walk. In **paginated** mode the tools, prompts, and resources + * panels do not render that aggregate at all — they render the paged store, + * which fetches one page at a time through a different path that neither writes + * nor clears this report. So a report written while the list was aggregated + * would simply persist, sitting above a freshly fetched page it does not + * describe: the same warning over a page that may be perfectly conforming, with + * indices that refer to positions in an aggregate the user is no longer + * looking at. + * + * Suppressing is the honest option of the two available. The alternative — + * giving the paged path its own per-page salvage and report — is a real feature + * rather than a display fix, and showing a stale warning is worse than showing + * none: this panel's whole purpose is to say which entry the server got wrong, + * so pointing at the wrong entry costs more than staying quiet. + * + * Resource templates are deliberately NOT suppressed. That list has no paged + * store — it is fetched through the aggregate in both modes — so its report is + * always about the list actually on screen. + */ +const PAGED_LIST_METHODS: readonly string[] = [ + "tools/list", + "prompts/list", + "resources/list", +]; + +/** + * The reports to render for the current pagination mode. + * + * Returns the input untouched in aggregate mode — the common case — so the + * memoized identity is preserved and nothing re-renders for a filter that + * removes nothing. + */ +export function visibleMalformedListItems( + items: MalformedListItem[], + paginated: boolean, +): MalformedListItem[] { + if (!paginated) return items; + return items.filter((item) => !PAGED_LIST_METHODS.includes(item.method)); +} diff --git a/core/mcp/listSalvage.ts b/core/mcp/listSalvage.ts index 1f2109c3..053b465b 100644 --- a/core/mcp/listSalvage.ts +++ b/core/mcp/listSalvage.ts @@ -229,21 +229,9 @@ export function describeIssues(error: z.ZodError): string { /* v8 ignore next -- zod never produces a failed parse with zero issues; guards against an empty message if it ever did */ if (!issue) return "did not match the expected shape"; const path = issue.path.join("."); - const message = describeIssue(issue); - return path.length > 0 ? `${path}: ${message}` : message; -} - -/** - * One issue's message, with the expected type folded in when it adds something. - * - * Split out from {@link describeIssues} because {@link eraToolItemSchema} has to - * apply it EARLY: forwarding another schema's issues re-emits them as `custom` - * ones, which carry a message and a path but no `expected` field, so the detail - * has to be baked into the message while the original issue is still in hand. - */ -function describeIssue(issue: z.core.$ZodIssue): string { const detail = expectedDetail(issue); - return detail ? `${issue.message} (${detail})` : issue.message; + const message = detail ? `${issue.message} (${detail})` : issue.message; + return path.length > 0 ? `${path}: ${message}` : message; } /** @@ -398,40 +386,29 @@ function stripExecution(tool: Tool): Tool { /** * Bridge a per-era WIRE schema to the `Tool` the rest of the client works with. * - * The two are deliberately separate, because on BOTH eras the wire schema - * accepts values the exported `Tool` type cannot describe — `properties: 5` is - * a valid modern `inputSchema` keyword set, and the SDK hands exactly that back - * typed as a `Tool`. So the era rules are expressed as an ordinary schema whose - * output type is irrelevant (it is only ever consulted for a verdict), and - * `z.custom` supplies the type the caller needs. That is what avoids the - * `as unknown as` this would otherwise require — and it is the same assertion - * the SDK itself makes, in the same place. + * The bridge exists because the two cannot be the same schema: on BOTH eras the + * wire shape accepts values the exported `Tool` type cannot describe — + * `properties: 5` is a valid modern `inputSchema` keyword set, and the SDK + * hands exactly that back typed as a `Tool`. So the era rules are expressed as + * an ordinary schema, and its parsed output is asserted to `Tool` here. * - * The wire schema's issues are copied across one by one, each keeping its PATH, - * rather than collapsed into a single "invalid tool" — so `describeIssues` can - * still name the offending field (`inputSchema.type`) instead of reporting a - * bare "Invalid input", which is the whole point of the report this module - * produces. They are re-emitted as `custom` issues (the only code that can - * carry an arbitrary forwarded message), which is why the expected-type detail - * is folded into the message here by {@link describeIssue} rather than left for - * `describeIssues` to recover from a field the new issue will not have. + * The parsed OUTPUT is what gets normalized and returned, not the raw input. + * That matters: both era schemas are `z.object`, so parsing strips unknown + * top-level fields (and unknown keys inside `annotations`) exactly as the + * strict codec does. Validating with the wire schema but returning the original + * value would leave salvaged tools carrying fields the strict path drops, so + * the two paths would hand the UI differently-shaped tools for the same server. + * + * The assertion is single and deliberate — `parsed` is `unknown`, so this is + * the narrow `as` the double-cast rule asks for rather than an `as unknown as` + * — and it is the same claim the SDK makes about its own era-parsed results. + * Issues come straight from the wire parse, so they keep their field paths and + * `describeIssues` can still name `inputSchema.type` rather than reporting a + * bare "Invalid input". */ function eraToolItemSchema( wire: z.ZodType, normalize: (tool: Tool) => Tool, ): z.ZodType { - return z - .custom() - .superRefine((value, ctx) => { - const parsed = wire.safeParse(value); - if (parsed.success) return; - for (const issue of parsed.error.issues) { - ctx.addIssue({ - code: "custom", - path: issue.path, - message: describeIssue(issue), - }); - } - }) - .transform(normalize); + return wire.transform((parsed) => normalize(parsed as Tool)); } From 562709a79a4c4d59d344a89261c59646bb5b2a56 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 20:11:59 -0400 Subject: [PATCH 36/58] fix: guard the surviving branch's own composition, via one shared predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 10: one finding, the last position of the rule the previous two rounds established. A *non-null* branch can carry its own `anyOf`, and the hoist drops it — the branch's `anyOf` overwrites the wrapper's in the spread and is then cleared, so `{ anyOf: [{ type: "string", anyOf: [{ const: "a" }] }, { type: "null" }] }` widened into an unconstrained nullable string. Rather than add a fourth inline condition, the four sites that ask "does this schema compose something I cannot evaluate?" now share one predicate, `hasUnevaluatedComposition` (an opaque applicator, or a nested `anyOf`): - the surviving branch in the `anyOf` collapse (this round's finding), - the null branch in `admitsNull`, - the `type: [T, "null"]` collapse path, - and, via `hasOpaqueApplicator`, the top of `admitsNull`. Each of those was missed independently across rounds 8-10 for the same reason: the guard existed but was spelled out inline at each site and drifted. Sharing the predicate is the part that stops a fifth site from repeating it. Signed-off-by: cliffhall --- .../web/src/test/core/nullableUnion.test.ts | 37 +++++++++++++++++ core/json/nullableUnion.ts | 40 +++++++++++++++---- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index 525dae29..2f125a82 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -450,6 +450,43 @@ describe("normalizeNullableUnion", () => { expect(normalizeNullableUnion(schema)).toBe(schema); }); + // The hoist *drops* a nested union: the branch's `anyOf` overwrites the + // wrapper's in the spread and is then cleared, so the constraint vanishes and + // the field widens into an unconstrained nullable string. + it("declines when the surviving branch carries its own anyOf", () => { + const schema = { + anyOf: [ + { type: "string", anyOf: [{ const: "a" }] }, + { type: "null" as const }, + ], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("declines when the surviving branch carries an opaque applicator", () => { + const withNot = { + anyOf: [ + { type: "string", not: { const: "a" } }, + { type: "null" as const }, + ], + }; + expect(normalizeNullableUnion(withNot)).toBe(withNot); + const withAllOf = { + anyOf: [ + { type: "string", allOf: [{ minLength: 2 }] }, + { type: "null" as const }, + ], + }; + expect(normalizeNullableUnion(withAllOf)).toBe(withAllOf); + const withOneOf = { + anyOf: [ + { type: "string", oneOf: [{ const: "a" }] }, + { type: "null" as const }, + ], + }; + expect(normalizeNullableUnion(withOneOf)).toBe(withOneOf); + }); + it("declines when the wrapper carries an applicator the module cannot read", () => { const schema = { allOf: [{ minLength: 2 }], diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 2e4db037..7db747f4 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -205,6 +205,26 @@ function hasOpaqueApplicator(schema: NullableUnionSchema): boolean { ); } +/** + * Whether a schema composes further constraints this module cannot evaluate — + * {@link hasOpaqueApplicator}, **plus a nested `anyOf`**. + * + * This is the single test every position uses on a schema that is *not* the + * union being recognized: the surviving branch, a null branch, and the + * `type: [T, "null"]` schema. Each of those was missed independently at some + * point — the guard existed but was spelled out inline and drifted between + * sites — so they now share one predicate rather than three conditions that + * have to be kept in step. + * + * A nested `anyOf` matters as much as the others because the hoist *drops* it: + * the branch's `anyOf` overwrites the wrapper's in the spread and is then + * cleared, so `{ anyOf: [{ type: "string", anyOf: [...] }, { type: "null" }] }` + * would otherwise widen into an unconstrained nullable string. + */ +function hasUnevaluatedComposition(schema: NullableUnionSchema): boolean { + return schema.anyOf !== undefined || hasOpaqueApplicator(schema); +} + /** * What {@link stripNullEnumMembers} concluded about an `enum`. * @@ -378,12 +398,12 @@ export function admitsNull(schema: NullableUnionSchema): boolean { // const: "x" }` is unsatisfiable, and its own applicators are as opaque // here as the wrapper's. const branchSchema = branch as NullableUnionSchema; + // A branch that names null can still admit nothing: `{ type: "null", + // const: "x" }` is unsatisfiable, and a nested union or applicator inside + // it is as opaque here as one on the wrapper. return ( !nullExcludedBySiblings(branchSchema) && - !hasOpaqueApplicator(branchSchema) && - // A nested union inside the branch is as opaque as the others: `{ type: - // "null", anyOf: [{ type: "string" }] }` names null and admits nothing. - branchSchema.anyOf === undefined + !hasUnevaluatedComposition(branchSchema) ); }) ?? false ); @@ -424,7 +444,14 @@ export function normalizeNullableUnion( // Hoisting would silently drop a wrapper constraint the branch also // carries, so decline instead; see `branchDropsWrapperConstraint`. - if (nullBranch && branch && !wrapperCarriesConstraints(schema)) { + // The wrapper may only carry annotations, and the surviving branch may not + // compose anything this module cannot evaluate — the hoist would drop it. + if ( + nullBranch && + branch && + !wrapperCarriesConstraints(schema) && + !hasUnevaluatedComposition(branch as NullableUnionSchema) + ) { // A branch may carry an `enum` and no `type`; JSON Schema allows that, and // an all-string enum is unambiguously a string field. See isStringEnum for // why a non-string enum deliberately does not get the same treatment. @@ -449,8 +476,7 @@ export function normalizeNullableUnion( // and the renderers ignore the other applicators — so a compound schema // like `{ type: ["string", "null"], anyOf: [{ const: "a" }] }` would be // widened into an unconstrained string field. Leave it for the JSON editor. - schema.anyOf === undefined && - !hasOpaqueApplicator(schema) + !hasUnevaluatedComposition(schema) ) { const type = schema.type.find((member) => member !== "null"); if (isRenderableType(type)) { From efd0bf2b7c7eaab10b58986f68303ddd674c958a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 20:23:59 -0400 Subject: [PATCH 37/58] fix: let the aggregate own the tools/list report, and harden the scan (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 9. The exclusion scan is a separate, uncached tools/list exchange, so its entries need not be the ones rendered — the server can answer differently, and a cache-served aggregate is a different list version. It was overwriting the aggregate's report wholesale, replacing accurate indices with indices into a list nobody is looking at. The aggregate now owns the report; the scan publishes only when the aggregate left nothing, which is the cache-served case that made it publish at all (round 2). A conforming re-fetch no longer swallows the mark: the list can change between the strict page and the lenient one, and a clean second response says nothing about the first, which was refused. It is marked with the original decode error when there is nothing per-item to name. The scan's fallback re-fetch now goes through invokeMcpClient, like the strict call beside it and like salvageList's walk, so an auth challenge on that request can still be recovered instead of being swallowed by listAllTools's best-effort scan catch. Also fixes listTools dropping an empty-string cursor (truthiness rather than !== undefined) — its own fallback leg already preserved it, so the two legs of one scan page disagreed about which page they had fetched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E Signed-off-by: cliffhall --- .../inspectorClient-malformed-list.test.ts | 101 ++++++++++++++++++ core/mcp/inspectorClient.ts | 80 ++++++++++---- 2 files changed, 160 insertions(+), 21 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts index c9224ff1..6a8b88cd 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts @@ -610,6 +610,107 @@ describe("InspectorClient list salvage (#1909)", () => { expect(connected.getMalformedListItems()).toEqual([]); }); + it("does not let the scan overwrite the aggregate's report", async () => { + // Both walks run on a modern connection and they are separate, uncached + // exchanges. The aggregate is the list being RENDERED, so its report owns + // the indices; the scan must not replace them with its own — the server can + // answer the second request differently, and then the warning would point + // into a list nobody is looking at. + const server = await startMalformedServer( + { + tools: [ + { + items: [ + { name: "ok_tool", inputSchema: { type: "object" } }, + { name: "broken", inputSchema: "not-a-schema" }, + ], + }, + ], + }, + true, + ); + stopServer = server.stop; + const connected = await connectTo(server.url, "modern"); + + // Serve a DIFFERENT malformed shape to the scan's walk, which runs after + // the aggregate — so an overwrite is visible as a changed index/label. + let swapped = false; + const original = connected.listTools.bind(connected); + connected.listTools = async (cursor?: string, meta?: object) => { + if (!swapped) { + swapped = true; + server.setPages({ + tools: [ + { + items: [ + { name: "a", inputSchema: { type: "object" } }, + { name: "b", inputSchema: { type: "object" } }, + { name: "later_break", inputSchema: 42 }, + ], + }, + ], + }); + } + return original(cursor, meta as Record | undefined); + }; + + await connected.listAllTools(); + + // The aggregate's finding, at the aggregate's index — not the scan's. + expect(connected.getMalformedListItems()).toEqual([ + { + method: "tools/list", + index: 1, + label: "broken", + reason: expect.stringMatching(/^inputSchema/), + }, + ]); + }); + + it("marks the scan's refused response even when the re-fetch is clean", async () => { + // The list can change between the strict page and the lenient re-fetch. A + // conforming re-fetch says nothing about the response that was refused, so + // leaving it unmarked would render a rejected exchange as a clean success. + const server = await startMalformedServer( + { tools: [{ items: [{ name: "broken", inputSchema: 42 }] }] }, + true, + ); + stopServer = server.stop; + const connected = await connectTo(server.url, "modern"); + const log = new MessageLogState(connected); + + // Let the strict call fail, then serve a conforming list to every later + // request — so the scan's re-fetch finds nothing per-item wrong. + const original = connected.listTools.bind(connected); + connected.listTools = async (cursor?: string, meta?: object) => { + try { + return await original( + cursor, + meta as Record | undefined, + ); + } finally { + server.setPages({ + tools: [ + { items: [{ name: "fixed", inputSchema: { type: "object" } }] }, + ], + }); + } + }; + + await connected.refreshExcludedTools(); + + const marked = log + .getMessages() + .filter( + (entry) => + "method" in entry.message && + entry.message.method === "tools/list" && + entry.clientError !== undefined, + ); + expect(marked).toHaveLength(1); + log.destroy(); + }); + it("stops the salvage walk at the page cap instead of paging forever", async () => { // A server whose `nextCursor` never converges but never REPEATS slips the // repeated-cursor guard: every cursor is fresh, so the walk had no reason diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index cc7f0b65..d7bb2078 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -3178,7 +3178,11 @@ export class InspectorClient extends InspectorClientEventTarget { const effectiveMeta = this.mergeMeta(metadata); const params: ListToolsRequest["params"] = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), - ...(cursor ? { cursor } : {}), + // `!== undefined`, not truthiness: "" is a valid cursor. Dropping it asks + // for page one again — and `listToolsForScan` pairs this call with a + // fallback that already preserves it, so the two legs of one scan page + // would otherwise disagree about which page they fetched. + ...(cursor !== undefined && { cursor }), }; const response = await this.invokeMcpClient(() => this.client!.request( @@ -3617,13 +3621,26 @@ export class InspectorClient extends InspectorClientEventTarget { } } while (cursor !== undefined); } - // Report what the scan itself had to drop. This walk is deliberately - // un-cached, so it can see a malformed tool the `listAllTools` aggregate - // never did — that aggregate may have been served from the response cache, - // in which case its salvage never ran and cleared the report instead. Only - // ever ADDS: a scan that finds nothing must not clear a report the salvage - // path just wrote from its own walk of the same list. - if (malformed.length > 0) { + // Report what the scan itself had to drop — but only when the aggregate + // salvage left nothing to say. + // + // The aggregate OWNS this report, because the aggregate is the list the + // user is looking at. This walk is a second, deliberately un-cached + // exchange, so its entries need not be the rendered ones: the server can + // change between the two requests, and a cache-served aggregate is a + // different list version entirely. Overwriting an aggregate report with + // this one would then replace accurate indices with indices into a list + // nobody is seeing. + // + // It still publishes when the aggregate said nothing, which is the case + // this exists for: a cache-served aggregate succeeds without ever running + // salvage, so without this a malformed tool would be dropped from the scan + // with no explanation anywhere. `setMalformedListItems` is not called at + // all when empty, so a clean scan cannot clear what the aggregate wrote. + if ( + malformed.length > 0 && + this.malformedListItems.get("tools/list") === undefined + ) { this.setMalformedListItems("tools/list", malformed); } this.excludedTools = excluded; @@ -3674,17 +3691,26 @@ export class InspectorClient extends InspectorClientEventTarget { ListToolsResultSchema, "tools", ); - const page = this.isModernEra() - ? await this.rawWireRequest( - "tools/list", - this.withModernEnvelope(params), - pageSchema, - ) - : await this.client!.request( - { method: "tools/list", params }, - pageSchema, - this.getRequestOptions(metadata?.progressToken), - ); + // Through `invokeMcpClient`, like the strict `listTools` above and like + // `salvageList`'s walk: this re-fetch can meet an auth challenge of its + // own, and outside that wrapper the recovery never runs. Its failure is + // then swallowed by `listAllTools`'s best-effort scan catch, leaving a + // stale excluded-tools set and no sign of why. + const page = await this.invokeMcpClient( + () => + this.isModernEra() + ? this.rawWireRequest( + "tools/list", + this.withModernEnvelope(params), + pageSchema, + ) + : this.client!.request( + { method: "tools/list", params }, + pageSchema, + this.getRequestOptions(metadata?.progressToken), + ), + { method: "tools/list" }, + ); // Same era-envelope check as `salvageList` — the raw-wire path bypasses // the codec that would otherwise enforce it. if ( @@ -3702,10 +3728,22 @@ export class InspectorClient extends InspectorClientEventTarget { items, schema: toolItemSchemaForEra(this.isModernEra()), }); - if (rejectedResponseId !== undefined && malformed.length > 0) { + // Mark regardless of what the re-fetch found. The list can change + // between the two requests, so a conforming re-fetch is possible — and + // it says nothing about the response that was actually refused. Leaving + // that one unmarked because the SECOND request came back clean would + // render a rejected exchange as a clean success, which is the lie + // #1953 removes. With nothing per-item to name, the original decode + // error is the reason. + if (rejectedResponseId !== undefined) { this.dispatchTypedEvent("responseRejected", { id: rejectedResponseId, - reason: summarizeMalformed(malformed), + reason: + malformed.length > 0 + ? summarizeMalformed(malformed) + : err instanceof Error + ? err.message + : String(err), }); } const nextCursor = nextCursorOf(page); From 18053d4cb2dfeba7012590b407a809afd302e820 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 20:27:19 -0400 Subject: [PATCH 38/58] fix: decline empty enums, drop mislabelling enumNames, treat $ref as opaque MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 11. Two of the three are the enum-strip logic this PR added misbehaving on degenerate input; the third is one more keyword for the fail-closed rule. 1. An empty `enum` permits nothing at all, but the strip reported "unchanged" (nothing was removed) and the collapse built a Select with no options — a required field then had no interaction able to supply a value. It now reaches the same conclusion as `[null]`: no selectable member, so decline. The outcome is renamed `unselectable`, since "only-null" no longer describes both routes to it. 2. Filtering `null` out of `enum` shortens it, which could make an ALREADY mismatched `enumNames` come out the same length — so `enum: [null, "b"]` with `enumNames: ["None"]` started labelling `b` as "None", turning labels both renderers had correctly ignored into a confident mislabel. A list that was not parallel before filtering is now dropped rather than kept. 3. `$ref` joins the opaque set. It is never resolved here and applies alongside its siblings, so `{ type: "string", $ref: "#/$defs/intOnly" }` can be unsatisfiable. The wrapper path already declined on it by virtue of the annotation allowlist; this extends the same rule to branches, the `type: [T, "null"]` path, and `admitsNull`. No round 12 was requested: the review loop was stopped at the user's direction. Signed-off-by: cliffhall --- .../web/src/test/core/nullableUnion.test.ts | 55 ++++++++++++++++++- core/json/nullableUnion.ts | 53 +++++++++++------- 2 files changed, 85 insertions(+), 23 deletions(-) diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index 2f125a82..eb22ab2e 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -341,7 +341,27 @@ describe("normalizeNullableUnion", () => { }); }); - it("leaves a mismatched enumNames alone rather than inventing an alignment", () => { + // Keeping a mismatched list is not neutral: filtering shortens `enum`, so a + // list both renderers had correctly *ignored* can come out the same length + // and start labelling — `enum: [null, "b"]` + `enumNames: ["None"]` would + // otherwise label `b` as "None". Dropping it preserves the ignore. + it("drops a mismatched enumNames rather than letting filtering align it", () => { + expect( + normalizeNullableUnion({ + type: ["string", "null"], + enum: [null, "b"], + enumNames: ["None"], + }), + ).toEqual({ + type: "string", + enum: ["b"], + enumNames: undefined, + anyOf: undefined, + nullable: true, + }); + }); + + it("drops a mismatched enumNames that stays mismatched too", () => { expect( normalizeNullableUnion({ type: ["string", "null"], @@ -351,12 +371,43 @@ describe("normalizeNullableUnion", () => { ).toEqual({ type: "string", enum: ["a"], - enumNames: ["Alpha"], + enumNames: undefined, anyOf: undefined, nullable: true, }); }); + // An empty enum permits nothing at all, so it reaches the same conclusion as + // `[null]`: no value a dropdown could offer, so do not build one. + it("declines to collapse an empty enum", () => { + const typeArray = { type: ["string", "null"], enum: [] }; + expect(normalizeNullableUnion(typeArray)).toBe(typeArray); + const union = { + anyOf: [{ type: "string", enum: [] }, { type: "null" as const }], + }; + expect(normalizeNullableUnion(union)).toBe(union); + }); + + // `$ref` applies alongside its siblings and is never resolved here, so a + // branch carrying one can be unsatisfiable. + it("treats $ref as opaque wherever it appears", () => { + const onBranch = { + anyOf: [ + { type: "string", $ref: "#/$defs/intOnly" }, + { type: "null" as const }, + ], + }; + expect(normalizeNullableUnion(onBranch)).toBe(onBranch); + const onTypeArray = { type: ["string", "null"], $ref: "#/$defs/intOnly" }; + expect(normalizeNullableUnion(onTypeArray)).toBe(onTypeArray); + expect(admitsNull({ type: ["string", "null"], $ref: "#/$defs/x" })).toBe( + false, + ); + expect(admitsNull({ anyOf: [{ type: "null", $ref: "#/$defs/x" }] })).toBe( + false, + ); + }); + // The type-array path keeps the schema's own keywords, but `collapsed` clears // `anyOf` and the renderers ignore the other applicators — so a compound // schema would be widened into an unconstrained field rather than collapsed. diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 7db747f4..e4cc8b24 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -47,6 +47,7 @@ export interface NullableUnionSchema { oneOf?: readonly unknown[]; allOf?: readonly unknown[]; not?: unknown; + $ref?: string; nullable?: boolean; const?: unknown; } @@ -201,7 +202,12 @@ function hasOpaqueApplicator(schema: NullableUnionSchema): boolean { return ( schema.not !== undefined || schema.allOf !== undefined || - schema.oneOf !== undefined + schema.oneOf !== undefined || + // `$ref` points at a schema this module never resolves, and it applies + // *alongside* its siblings — `{ type: "string", $ref: "#/$defs/intOnly" }` + // can be unsatisfiable. The wrapper path already declines on it (it is not + // an annotation); this is the same rule for every other position. + schema.$ref !== undefined ); } @@ -228,13 +234,13 @@ function hasUnevaluatedComposition(schema: NullableUnionSchema): boolean { /** * What {@link stripNullEnumMembers} concluded about an `enum`. * - * `"only-null"` is a distinct outcome rather than "an empty list" because it + * `"unselectable"` is a distinct outcome rather than "an empty list" because it * has to **stop the collapse**, not just empty a field. See below. */ type EnumStrip = | { kind: "unchanged" } | { kind: "filtered"; members: unknown[]; names?: unknown } - | { kind: "only-null" }; + | { kind: "unselectable" }; /** * Drop `null` members from an `enum`, keeping the parallel `enumNames` aligned. @@ -252,12 +258,13 @@ type EnumStrip = * the two lengths disagree — so stripping one without the other silently loses * every label rather than just the dropped one's. * - * **An enum of nothing but `null` reports `"only-null"` so the caller declines - * to collapse at all.** Emitting `enum: undefined` instead would turn a schema - * permitting *only* `null` into a plain string field that accepts arbitrary - * text — trading a cosmetic problem for a correctness one, since the form would - * then invite values the schema forbids. Left uncollapsed it renders through - * the JSON editor, which represents it honestly. + * **An enum that offers no selectable value reports `"unselectable"`, so the + * caller declines to collapse at all.** That covers `[null]` and the empty + * `[]`: the first permits only `null`, the second permits nothing. Emitting + * `enum: undefined` instead would turn either into a plain string field + * accepting arbitrary text — trading a cosmetic problem for a correctness one, + * since the form would then invite values the schema forbids. Left uncollapsed + * they render through the JSON editor, which represents them honestly. */ function stripNullEnumMembers(members: unknown, names: unknown): EnumStrip { if (!Array.isArray(members)) { @@ -266,19 +273,23 @@ function stripNullEnumMembers(members: unknown, names: unknown): EnumStrip { const kept = members .map((member, index) => ({ member, index })) .filter((entry) => entry.member !== null); + // An empty `enum` permits nothing at all, so it reaches the same conclusion + // as `[null]` by a different route: there is no value a dropdown could offer. + if (kept.length === 0) { + return { kind: "unselectable" }; + } if (kept.length === members.length) { return { kind: "unchanged" }; } - if (kept.length === 0) { - return { kind: "only-null" }; - } - // Only realign names that were positionally parallel to begin with; a - // mismatched list is already ignored by both renderers, so re-indexing it - // would invent an alignment the server never declared. - const aligned = - Array.isArray(names) && names.length === members.length - ? kept.map((entry) => names[entry.index]) - : names; + // Only realign names that were positionally parallel to begin with, and DROP + // them otherwise. Keeping a mismatched list is not neutral: filtering shortens + // `enum`, so `enum: [null, "b"]` with `enumNames: ["None"]` would come out the + // same length and start *labelling* `b` as "None" — turning labels both + // renderers had correctly ignored into a confident mislabel. + const wasParallel = Array.isArray(names) && names.length === members.length; + const aligned = wasParallel + ? kept.map((entry) => names[entry.index]) + : undefined; return { kind: "filtered", members: kept.map((entry) => entry.member), @@ -309,7 +320,7 @@ function collapsed( merged.enum, (merged as { enumNames?: unknown }).enumNames, ); - if (strip.kind === "only-null") { + if (strip.kind === "unselectable") { return null; } return { @@ -458,7 +469,7 @@ export function normalizeNullableUnion( const type = branch.type ?? (isStringEnum(branch.enum) ? "string" : undefined); if (isRenderableType(type)) { - // `null` when the merged enum permits only `null`, which is not + // `null` when the merged enum offers nothing selectable, which is not // collapsible — fall through and return the schema by identity. const result = collapsed(schema, branch, type); if (result !== null) { From 77ff30bbc940ffdab717f781530eff15a4d8087a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 20:38:10 -0400 Subject: [PATCH 39/58] fix: stop the exclusion scan writing the rendered list's report (#1909) Copilot round 10. The scan no longer publishes its malformed entries into the tools/list report at all. Round 9 only stopped it overwriting the aggregate's; the remaining case is unsalvageable for a stronger reason than 'two different requests': - a FAILED strict aggregate runs salvage, which writes the report itself; - a SUCCESSFUL one means every entry parsed, so the rendered list contains no malformed entries. So by the time the scan finds something, either the aggregate already reported it with indices matching the screen, or the aggregate succeeded and what the scan found is provably not in the list being displayed. Publishing could only ever name entries the user cannot see. Nothing is lost: listToolsForScan still marks its own refused response, so the finding surfaces on the Protocol entry for the exchange that carried it, where it is true. The per-page accumulation and raw-entry offset existed only to feed the removed publish, and go with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E Signed-off-by: cliffhall --- .../inspectorClient-malformed-list.test.ts | 20 +++--- core/mcp/inspectorClient.ts | 65 +++++++------------ 2 files changed, 34 insertions(+), 51 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts index 6a8b88cd..64dcafbe 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts @@ -535,10 +535,12 @@ describe("InspectorClient list salvage (#1909)", () => { log.destroy(); }); - it("reports scan-dropped tools at their position in the whole list", async () => { - // The exclusion scan walks pages of its own, so its report has to be - // offset by the RAW entries of PRIOR pages. An unlabeled entry's index is - // all the user has to go on, so a wrong offset points at nothing. + it("indexes a dropped tool against the whole aggregate, across pages", async () => { + // The reported index has to be offset by the RAW entries of PRIOR pages — + // valid and malformed alike, not just the kept ones. An unlabeled entry's + // index is all the user has to go on, so a wrong offset points at nothing. + // This is the aggregate salvage's walk: it owns the `tools/list` report, + // because it is the walk that produced the list being rendered. const server = await startMalformedServer( { tools: [ @@ -610,12 +612,12 @@ describe("InspectorClient list salvage (#1909)", () => { expect(connected.getMalformedListItems()).toEqual([]); }); - it("does not let the scan overwrite the aggregate's report", async () => { + it("keeps the aggregate's report when the scan sees a different list", async () => { // Both walks run on a modern connection and they are separate, uncached - // exchanges. The aggregate is the list being RENDERED, so its report owns - // the indices; the scan must not replace them with its own — the server can - // answer the second request differently, and then the warning would point - // into a list nobody is looking at. + // exchanges. The aggregate is the list being RENDERED, so it owns the + // report; the scan never writes it. Here the server answers the scan's + // request with a different malformed shape — which, if the scan published, + // would leave the warning pointing into a list nobody is looking at. const server = await startMalformedServer( { tools: [ diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index d7bb2078..333020aa 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -3579,10 +3579,6 @@ export class InspectorClient extends InspectorClientEventTarget { metadata?: Record, ): Promise { const excluded: ExcludedTool[] = []; - const malformed: MalformedListItem[] = []; - // Raw entries seen so far, across pages — the frame the reported indices - // are relative to (see the mapping below). - let rawOffset = 0; // Gated to connections that actually exclude; otherwise this is a pure // no-op (no round trip). The raw `listTools` below guards the connection. if (this.excludesInvalidXMcpHeaderTools()) { @@ -3597,18 +3593,6 @@ export class InspectorClient extends InspectorClientEventTarget { if (pages >= LIST_MAX_PAGES) throw listPaginationExceeded("tools/list"); pages += 1; const page = await this.listToolsForScan(cursor, metadata); - malformed.push( - ...page.malformed.map((entry) => ({ - ...entry, - // Offset by the RAW entries of PRIOR pages only. `excluded.length` - // would be wrong twice over: it counts just the invalid-header - // tools, and on page one it would also fold in this page's own - // valid count — putting an unlabeled entry, whose index is all the - // user has to go on, at a position that doesn't exist. - index: entry.index + rawOffset, - })), - ); - rawOffset += page.tools.length + page.malformed.length; for (const tool of page.tools) { const scan = scanXMcpHeaderDeclarations(tool.inputSchema); if (!scan.valid) excluded.push({ tool, reason: scan.reason }); @@ -3621,28 +3605,24 @@ export class InspectorClient extends InspectorClientEventTarget { } } while (cursor !== undefined); } - // Report what the scan itself had to drop — but only when the aggregate - // salvage left nothing to say. + // The scan deliberately does NOT publish what it dropped into the + // `tools/list` report. That report is rendered above the Tools list as + // "entries dropped from this list", and the aggregate is what that list + // is — so only the aggregate's salvage may write it. // - // The aggregate OWNS this report, because the aggregate is the list the - // user is looking at. This walk is a second, deliberately un-cached - // exchange, so its entries need not be the rendered ones: the server can - // change between the two requests, and a cache-served aggregate is a - // different list version entirely. Overwriting an aggregate report with - // this one would then replace accurate indices with indices into a list - // nobody is seeing. + // The reason is stronger than "these are two different requests". A + // successful strict aggregate means every entry parsed, so the rendered + // list has no malformed entries in it at all; and a FAILED aggregate runs + // salvage, which writes the report itself. So by the time this walk finds + // something, either the aggregate already reported it (with the indices + // that match what is on screen), or the aggregate succeeded and whatever + // this walk just found is provably not in the list being displayed — the + // server changed, or the aggregate came from cache. Publishing here can + // therefore only ever name entries the user cannot see. // - // It still publishes when the aggregate said nothing, which is the case - // this exists for: a cache-served aggregate succeeds without ever running - // salvage, so without this a malformed tool would be dropped from the scan - // with no explanation anywhere. `setMalformedListItems` is not called at - // all when empty, so a clean scan cannot clear what the aggregate wrote. - if ( - malformed.length > 0 && - this.malformedListItems.get("tools/list") === undefined - ) { - this.setMalformedListItems("tools/list", malformed); - } + // The finding is not lost: `listToolsForScan` marks the scan's own refused + // response, so it surfaces on the Protocol entry for the exchange that + // actually carried it, which is where it is true. this.excludedTools = excluded; this.dispatchTypedEvent("excludedToolsChange", excluded); return excluded; @@ -3657,8 +3637,12 @@ export class InspectorClient extends InspectorClientEventTarget { * which would blank the excluded set and leave a tool that vanished for an * invalid header with no explanation at all. That is the same failure #1909 * is about, one level down. So on a decode rejection the page is re-fetched - * leniently and scanned entry by entry; the malformed ones are not "excluded - * tools" and are reported through the salvage channel instead. + * leniently and scanned entry by entry. + * + * The malformed ones are not "excluded tools" and are not returned: they + * describe THIS exchange, not the aggregate the Tools panel renders, so they + * surface as the rejection reason on this response's own Protocol entry and + * go no further (see `refreshExcludedTools`). */ private async listToolsForScan( cursor: string | undefined, @@ -3666,11 +3650,9 @@ export class InspectorClient extends InspectorClientEventTarget { ): Promise<{ tools: Tool[]; nextCursor?: string; - malformed: MalformedListItem[]; }> { try { - const page = await this.listTools(cursor, metadata); - return { ...page, malformed: [] }; + return await this.listTools(cursor, metadata); } catch (err) { if (!isSalvageableRejection(err)) throw err; // Capture before the re-fetch, for the reason `salvageList` documents: @@ -3750,7 +3732,6 @@ export class InspectorClient extends InspectorClientEventTarget { return { tools: valid, ...(nextCursor !== undefined && { nextCursor }), - malformed, }; } catch (fallbackErr) { // The fallback could not explain the rejection — the re-fetch itself From 3a22d4eedca032d43fa5e0e88d2f4df58b2e987a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 21:13:11 -0400 Subject: [PATCH 40/58] fix: disambiguate the two token rows' controls for screen readers (#2019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 1. With an ID Token row alongside the Access Token row, both can render a "Decode JWT" toggle and a "Copy" control, and the four buttons shared two accessible names — button-by-button screen-reader navigation could not tell which token a control acted on. That collision is introduced by this PR: before it there was only ever one token row. - `CopyButton` takes an optional `label` naming what is being copied, used for the accessible name only ("Copy ID Token"). The visible tooltip stays "Copy", and the name keeps "Copy"/"Copied" as its first word so it still contains the visible text (WCAG 2.5.3 Label in Name). - `OAuthTokenField` qualifies its decode toggle the same way ("Decode JWT for Access Token") and passes its row label down to `CopyButton`. The visible UI is unchanged — this is accessible-name only. Signed-off-by: cliffhall --- .../elements/CopyButton/CopyButton.test.tsx | 11 ++++++ .../elements/CopyButton/CopyButton.tsx | 14 ++++++- .../ConnectionInfoContent.stories.tsx | 4 +- .../ConnectionInfoContent.test.tsx | 35 +++++++++++++++++ .../OAuthTokenField.test.tsx | 39 +++++++++++++++---- .../ConnectionInfoContent/OAuthTokenField.tsx | 3 +- 6 files changed, 94 insertions(+), 12 deletions(-) diff --git a/clients/web/src/components/elements/CopyButton/CopyButton.test.tsx b/clients/web/src/components/elements/CopyButton/CopyButton.test.tsx index 9a4b462a..0b663cae 100644 --- a/clients/web/src/components/elements/CopyButton/CopyButton.test.tsx +++ b/clients/web/src/components/elements/CopyButton/CopyButton.test.tsx @@ -10,6 +10,17 @@ describe("CopyButton", () => { expect(screen.getByText("⎘")).toBeInTheDocument(); }); + it("names itself 'Copy' with no label, and qualifies the name when given one", () => { + const { unmount } = renderWithMantine(); + expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + unmount(); + + renderWithMantine(); + expect( + screen.getByRole("button", { name: "Copy ID Token" }), + ).toBeInTheDocument(); + }); + it("triggers a click without throwing when clipboard is unavailable", async () => { const user = userEvent.setup(); renderWithMantine(); diff --git a/clients/web/src/components/elements/CopyButton/CopyButton.tsx b/clients/web/src/components/elements/CopyButton/CopyButton.tsx index 7a86ca08..5b87719d 100644 --- a/clients/web/src/components/elements/CopyButton/CopyButton.tsx +++ b/clients/web/src/components/elements/CopyButton/CopyButton.tsx @@ -11,6 +11,15 @@ export interface CopyButtonProps { * rows (e.g. beside a Code block). Icon size is unchanged. */ flush?: boolean; + /** + * Names what is being copied, for the accessible label only ("Copy ID + * Token"). Set it when more than one CopyButton can appear in the same + * region, so button-by-button screen-reader navigation can tell them apart; + * a lone control needs no qualifier. The visible tooltip stays "Copy" either + * way, and the accessible name keeps "Copy"/"Copied" as its first word so it + * still contains the visible text (WCAG 2.5.3). + */ + label?: string; } const CopyActionIcon = ActionIcon.withProps({ @@ -18,7 +27,8 @@ const CopyActionIcon = ActionIcon.withProps({ fz: 24, }); -export function CopyButton({ value, flush = false }: CopyButtonProps) { +export function CopyButton({ value, flush = false, label }: CopyButtonProps) { + const qualifier = label ? ` ${label}` : ""; return ( {({ copied, copy }) => ( @@ -26,7 +36,7 @@ export function CopyButton({ value, flush = false }: CopyButtonProps) { {copied ? "\u2713" : "\u2398"} diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx index a634b9de..c6f1e598 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx @@ -189,7 +189,9 @@ export const WithOAuthIdToken: Story = { const canvas = within(canvasElement); expect(canvas.getByText("ID Token")).toBeInTheDocument(); // Only the ID token is a JWT here, so exactly one decode toggle is offered. - await userEvent.click(canvas.getByRole("button", { name: "Decode JWT" })); + await userEvent.click( + canvas.getByRole("button", { name: "Decode JWT for ID Token" }), + ); expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index e33c63ad..f9f3d2fa 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -495,6 +495,41 @@ describe("ConnectionInfoContent", () => { expect(screen.getByText("id-token-456")).toBeInTheDocument(); }); + it("gives the two token rows' controls distinct accessible names", () => { + // Both rows carry a copy control, and both tokens here are JWTs so both + // carry a decode toggle. Screen-reader button navigation has only the + // accessible name to go on, so the four must not collide (#2019 review). + const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0."; + renderWithMantine( + , + ); + + const names = screen + .getAllByRole("button") + .map((button) => button.getAttribute("aria-label") ?? button.textContent) + .filter((name): name is string => Boolean(name)); + expect(new Set(names).size).toBe(names.length); + + for (const name of [ + "Copy Access Token", + "Copy ID Token", + "Decode JWT for Access Token", + "Decode JWT for ID Token", + ]) { + expect(screen.getByRole("button", { name })).toBeInTheDocument(); + } + }); + it("renders the ID Token row on its own when there is no access token", () => { renderWithMantine( { expect(screen.getByText("Access Token")).toBeInTheDocument(); expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument(); expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Copy Access Token" }), + ).toBeInTheDocument(); }); it("replaces raw token with decoded JSON and restores on toggle", async () => { @@ -20,11 +22,15 @@ describe("OAuthTokenField", () => { renderWithMantine(); expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Decode JWT" })); + await user.click( + screen.getByRole("button", { name: "Decode JWT for Access Token" }), + ); expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument(); expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Show token" })); + await user.click( + screen.getByRole("button", { name: "Show token for Access Token" }), + ); expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument(); expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument(); }); @@ -37,7 +43,7 @@ describe("OAuthTokenField", () => { />, ); expect( - screen.queryByRole("button", { name: "Decode JWT" }), + screen.queryByRole("button", { name: "Decode JWT for Access Token" }), ).not.toBeInTheDocument(); }); @@ -50,8 +56,10 @@ describe("OAuthTokenField", () => { }); renderWithMantine(); - await user.click(screen.getByRole("button", { name: "Decode JWT" })); - await user.click(screen.getByRole("button", { name: "Copy" })); + await user.click( + screen.getByRole("button", { name: "Decode JWT for Access Token" }), + ); + await user.click(screen.getByRole("button", { name: "Copy Access Token" })); expect(writeText).toHaveBeenCalledWith( expect.stringContaining('"sub": "user"'), @@ -68,7 +76,7 @@ describe("OAuthTokenField", () => { }); renderWithMantine(); - await user.click(screen.getByRole("button", { name: "Copy" })); + await user.click(screen.getByRole("button", { name: "Copy Access Token" })); expect(writeText).toHaveBeenCalledWith(jwt); }); @@ -101,10 +109,25 @@ describe("OAuthTokenField", () => { expect(screen.getByText("ID Token")).toBeInTheDocument(); expect(screen.queryByText("Access Token")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Decode JWT" })); + await user.click( + screen.getByRole("button", { name: "Decode JWT for ID Token" }), + ); expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument(); }); + it("qualifies the decode and copy accessible names with the row label", () => { + // Two rows can render simultaneously (Access Token + ID Token), so the + // controls must not share accessible names — see #2019 review. + renderWithMantine(); + + expect( + screen.getByRole("button", { name: "Decode JWT for ID Token" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Copy ID Token" }), + ).toBeInTheDocument(); + }); + it("omits the clear action when no handler is given", () => { renderWithMantine(); expect( diff --git a/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx index a9c19b2d..abd5c4a9 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx @@ -107,6 +107,7 @@ export function OAuthTokenField({ setShowDecoded((open) => !open)} aria-pressed={showDecoded} + aria-label={`${showDecoded ? "Show token" : "Decode JWT"} for ${label}`} > {showDecoded ? "Show token" : "Decode JWT"} @@ -130,7 +131,7 @@ export function OAuthTokenField({ )} - + ); From 362e2f419d4bec438eae62b180b6c8a376a45a2a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 21:14:21 -0400 Subject: [PATCH 41/58] test: build a real OAuthStorage in the EMA leak test (#2018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 1: the new EMA regression test constructed its inner provider's storage with `as unknown as OAuthStorage`, an unjustified double cast — the rule in AGENTS.md is that such a cast must carry a justification or be replaced. Replaced with the genuine article: `OAuthStorageBase` over an `OAuthMemoryStore` and a no-op persist backend, so the stub type-checks against the real interface the provider is handed and a future `OAuthStorage` contract change cannot pass silently. Reusing `emaConfig.storage`, as suggested, would not have addressed the objection — that value is itself a cast, and typed as `EmaFlowConfig["storage"]` rather than `OAuthStorage`. The test's assertion is unchanged and was re-verified to fail against pre-fix code (`expected 'corp-idp' to be null`). Signed-off-by: cliffhall --- .../core/auth/ema/transportProvider.test.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/clients/web/src/test/core/auth/ema/transportProvider.test.ts b/clients/web/src/test/core/auth/ema/transportProvider.test.ts index 7cf3acce..b6a3b32a 100644 --- a/clients/web/src/test/core/auth/ema/transportProvider.test.ts +++ b/clients/web/src/test/core/auth/ema/transportProvider.test.ts @@ -6,6 +6,9 @@ import { MutableRedirectUrlProvider, } from "@inspector/core/auth/providers.js"; import type { OAuthStorage } from "@inspector/core/auth/storage.js"; +import { OAuthStorageBase } from "@inspector/core/auth/oauth-storage.js"; +import { OAuthMemoryStore } from "@inspector/core/auth/store.js"; +import type { OAuthPersistBackend } from "@inspector/core/auth/oauth-persist.js"; import type { EmaFlowConfig } from "@inspector/core/auth/ema/emaFlow.js"; import { EmaTransportOAuthProvider } from "@inspector/core/auth/ema/transportProvider.js"; import { @@ -23,6 +26,20 @@ const startIdpMock = vi.mocked(startEmaIdpAuthorization); const SERVER_URL = "http://127.0.0.1:9999/mcp"; +/** + * A real `OAuthStorage` — in-memory state over a no-op persist backend — for the + * one test below that drives an actual `BaseOAuthClientProvider`. Building the + * genuine article costs two lines and avoids an `as unknown as` stub that would + * stop type-checking against the interface the provider is handed. + */ +function makeRealStorage(): OAuthStorage { + const backend: OAuthPersistBackend = { + read: async () => null, + write: async () => {}, + }; + return new OAuthStorageBase(new OAuthMemoryStore(), backend); +} + function jwtWithExp(expSec: number): string { const payload = btoa(JSON.stringify({ exp: expSec })) .replace(/\+/g, "-") @@ -200,9 +217,10 @@ describe("EmaTransportOAuthProvider", () => { it("does not leak custom authorization params onto the IdP authorize URL", async () => { const navCallback = vi.fn(); const realInner = new BaseOAuthClientProvider(SERVER_URL, { - storage: { - getScope: vi.fn().mockResolvedValue(undefined), - } as unknown as OAuthStorage, + // A real OAuthStorage rather than a cast stub: the redirect path never + // reads it, and a genuine implementation keeps this test honest if the + // storage contract changes. + storage: makeRealStorage(), redirectUrlProvider: new MutableRedirectUrlProvider(), navigation: new CallbackNavigation(navCallback), authorizationParams: { kc_idp_hint: "corp-idp", audience: "api://mcp" }, From 6002c2ce3359cd4bf2bbf8301102ead29387e228 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 21:09:51 -0400 Subject: [PATCH 42/58] fix: gate submission on unsendable field text, and on required tool args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field that cannot turn its text into a value reports `undefined` — which is exactly what an empty field reports. That made the two indistinguishable to every caller of `SchemaForm`, so invalid text in an optional field was submittable and the argument simply arrived absent (#2020). `SchemaForm` now tracks per-field draft validity and reports it through `onValidityChange`, covering both fields that hold text the form cannot send: the JSON editor (unparseable text) and the number input (a magnitude JS cannot hold exactly, which is dropped rather than silently rounded). The number field also states it on itself, as the JSON editor already did. A nested object form reports through the same channel, and both the field-level and form-level effects report "valid" on cleanup so a draft cannot outlive the field that held it — a remount under a new `resetKey`, or an unmounted panel. `ToolDetailPanel` gated Execute on `isExecuting` alone, so it also had no required-argument check at all — unlike the App and elicitation panels. It now uses `hasMissingRequiredFields` like they do. `InlineElicitationRequest` had neither gate and gets both. Signed-off-by: cliffhall --- .../AppDetailPanel/AppDetailPanel.test.tsx | 26 +++ .../groups/AppDetailPanel/AppDetailPanel.tsx | 9 +- .../ElicitationFormPanel.test.tsx | 28 +++ .../ElicitationFormPanel.tsx | 9 +- .../InlineElicitationRequest.test.tsx | 66 ++++++ .../InlineElicitationRequest.tsx | 24 ++- .../groups/SchemaForm/SchemaForm.test.tsx | 193 ++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 160 +++++++++++++-- .../ToolDetailPanel/ToolDetailPanel.test.tsx | 66 +++++- .../ToolDetailPanel/ToolDetailPanel.tsx | 21 +- 10 files changed, 582 insertions(+), 20 deletions(-) diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx index effbd5a2..ad14b1c3 100644 --- a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx @@ -173,4 +173,30 @@ describe("AppDetailPanel", () => { rerender(); expect(input().value).toBe(""); }); + + // #2020: `hasMissingRequiredFields` sees a field with no value, but text a + // field could not turn into a value looks identical to it — both report + // `undefined` — so an optional argument was dropped from the call with no + // gate. The form reports draft validity for this reason. + it("disables Open App while a field holds text it cannot send", async () => { + const user = userEvent.setup(); + const jsonFieldTool: Tool = { + name: "chart_app", + title: "Chart App", + inputSchema: { + type: "object", + properties: { series: { type: "array", title: "Series" } }, + }, + }; + renderWithMantine(); + const openApp = screen.getByRole("button", { name: /open app/i }); + expect(openApp).not.toBeDisabled(); + + const jsonInput = screen.getByLabelText(/Series/); + await user.type(jsonInput, "x"); + expect(openApp).toBeDisabled(); + + await user.clear(jsonInput); + expect(openApp).not.toBeDisabled(); + }); }); diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx index 8d5a2135..8d75f431 100644 --- a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx @@ -1,4 +1,5 @@ import { Button, Divider, ScrollArea, Stack, Text } from "@mantine/core"; +import { useState } from "react"; import { MdPlayArrow } from "react-icons/md"; import type { Tool } from "@modelcontextprotocol/client"; import { SchemaForm } from "../SchemaForm/SchemaForm"; @@ -58,7 +59,12 @@ export function AppDetailPanel({ /* v8 ignore next -- unreachable: Tool.inputSchema is always an object */ const formSchema = toFormSchema(inputSchema) ?? {}; const hasErrors = hasMissingRequiredFields(formSchema, formValues); - const disabled = isOpening || hasErrors; + // A field holding text it could not turn into a value (unparseable JSON, an + // unrepresentable number) reports `undefined`, which is indistinguishable + // from empty once it reaches `formValues` — so the form reports it directly + // and an optional argument can no longer be dropped silently (#2020). + const [hasInvalidDraft, setHasInvalidDraft] = useState(false); + const disabled = isOpening || hasErrors || hasInvalidDraft; const hasFields = hasInputFields(tool); return ( @@ -82,6 +88,7 @@ export function AppDetailPanel({ // tool's name to drop another app's in-progress field text. See // SchemaFormProps.resetKey. resetKey={tool.name} + onValidityChange={setHasInvalidDraft} /> { await user.type(screen.getByLabelText("Host"), "l"); expect(onChange).toHaveBeenCalledWith({ host: "l" }); }); + + // #2020: text a field cannot turn into a value reports `undefined`, so it is + // indistinguishable from an empty field in `values` — Submit stayed enabled + // and the answer went out without the property. + it("disables Submit while a field holds text it cannot send", async () => { + const user = userEvent.setup(); + const numberRequest: ElicitRequestFormParams = { + message: "Please provide the port.", + requestedSchema: { + type: "object" as const, + properties: { port: { type: "number" as const, title: "Port number" } }, + }, + }; + renderWithMantine( + , + ); + const submit = screen.getByRole("button", { name: "Submit" }); + expect(submit).toBeEnabled(); + + // Past MAX_SAFE_INTEGER the field reports no value rather than a rounded + // one, so the answer would go out without the property. + const input = screen.getByLabelText(/Port number/); + await user.type(input, "90071992547409910"); + expect(submit).toBeDisabled(); + + await user.clear(input); + expect(submit).toBeEnabled(); + }); }); diff --git a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx index 5ec4fddc..07802433 100644 --- a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx +++ b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx @@ -7,6 +7,7 @@ import { Stack, Text, } from "@mantine/core"; +import { useState } from "react"; import type { ElicitRequestFormParams } from "@modelcontextprotocol/client"; import type { PendingRequestOrigin } from "@inspector/core/mcp/types.js"; import { @@ -88,8 +89,13 @@ export function ElicitationFormPanel({ busy = false, }: ElicitationFormPanelProps) { const requestedSchema = request.requestedSchema as InspectorFormSchema; + // Text a field could not turn into a value never reaches `values`, so the + // form has to report it for Submit to see it at all (#2020). + const [hasInvalidDraft, setHasInvalidDraft] = useState(false); const submitDisabled = - busy || hasMissingRequiredFields(requestedSchema, values); + busy || + hasMissingRequiredFields(requestedSchema, values) || + hasInvalidDraft; return ( {formatQuoted(request.message)} @@ -101,6 +107,7 @@ export function ElicitationFormPanel({ values={values} onChange={onChange} disabled={busy} + onValidityChange={setHasInvalidDraft} /> {formatWarning(serverName)} diff --git a/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.test.tsx b/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.test.tsx index ea065596..b988e2b0 100644 --- a/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.test.tsx +++ b/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.test.tsx @@ -161,4 +161,70 @@ describe("InlineElicitationRequest", () => { ); expect(screen.getByText("Host")).toBeInTheDocument(); }); + + // #2020: this inline form had no submit gate at all — neither the missing + // required answer that `ElicitationFormPanel` already checked, nor text a + // field could not turn into a value (which never reaches `values`). + describe("submit gating (#2020)", () => { + const requiredRequest = { + message: "Please provide your name.", + requestedSchema: { + type: "object" as const, + properties: { name: { type: "string" as const, title: "Name" } }, + required: ["name"], + }, + } satisfies ElicitRequest["params"]; + + const numberRequest = { + message: "Please provide the port.", + requestedSchema: { + type: "object" as const, + properties: { port: { type: "number" as const, title: "Port number" } }, + }, + } satisfies ElicitRequest["params"]; + + it("disables Submit while a required answer is missing", () => { + renderWithMantine( + , + ); + expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); + }); + + it("enables Submit once the required answer is given", () => { + renderWithMantine( + , + ); + expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled(); + }); + + it("disables Submit while a field holds text it cannot send", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + const submit = screen.getByRole("button", { name: "Submit" }); + expect(submit).toBeEnabled(); + + // Past MAX_SAFE_INTEGER the field reports no value rather than a rounded + // one, so the answer would go out without the property. + const input = screen.getByLabelText(/Port number/); + await user.type(input, "90071992547409910"); + expect(submit).toBeDisabled(); + + await user.clear(input); + expect(submit).toBeEnabled(); + }); + }); }); diff --git a/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.tsx b/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.tsx index 14bc6fe8..7a85e6d2 100644 --- a/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.tsx +++ b/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.tsx @@ -13,7 +13,11 @@ import type { ElicitRequestFormParams, } from "@modelcontextprotocol/client"; import type { PendingRequestOrigin } from "@inspector/core/mcp/types.js"; -import type { InspectorFormSchema } from "../../../utils/jsonUtils"; +import { useState } from "react"; +import { + hasMissingRequiredFields, + type InspectorFormSchema, +} from "../../../utils/jsonUtils"; import { SchemaForm } from "../SchemaForm/SchemaForm"; import { MrtrOriginNote } from "../../elements/MrtrOriginNote/MrtrOriginNote"; @@ -86,6 +90,19 @@ export function InlineElicitationRequest({ onSubmit, onCancel, }: InlineElicitationRequestProps) { + // Same gate as `ElicitationFormPanel`, which this is the inline form of: a + // missing required answer, or a field holding text it could not turn into a + // value, must not be submittable — neither is visible in `values` (#2020). + const [hasInvalidDraft, setHasInvalidDraft] = useState(false); + const formValues = values ?? {}; + const submitDisabled = + isFormMode(request) && + (hasMissingRequiredFields( + request.requestedSchema as InspectorFormSchema, + formValues, + ) || + hasInvalidDraft); + return ( @@ -100,8 +117,9 @@ export function InlineElicitationRequest({ {isFormMode(request) && ( )} @@ -120,7 +138,7 @@ export function InlineElicitationRequest({ Cancel {isFormMode(request) && ( - )} diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index c2392ec8..6c7dd77b 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1284,3 +1284,196 @@ describe("SchemaForm nullable unions", () => { expect(screen.getByLabelText(/Mixed/).tagName).toBe("TEXTAREA"); }); }); + +// #2020: a field holding text it cannot turn into a value reports `undefined`, +// which is exactly what an *empty* field reports. That makes the two states +// indistinguishable to the caller, so invalid text in an optional field was +// submittable and simply arrived at the server absent. The form reports draft +// validity directly so a submit gate can see what `values` cannot. +describe("SchemaForm draft validity (#2020)", () => { + const jsonSchema: InspectorFormSchema = { + type: "object", + properties: { + config: { type: "array", title: "Config" }, + }, + }; + + function ValidityHarness({ + schema, + onValidityChange, + resetKey, + }: { + schema: InspectorFormSchema; + onValidityChange: (hasInvalidDraft: boolean) => void; + resetKey?: string; + }) { + const [values, setValues] = useState>({}); + return ( + + ); + } + + it("reports an empty optional field as valid", () => { + const onValidityChange = vi.fn(); + renderWithMantine( + , + ); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + }); + + it("reports an unparseable JSON draft, then clears it once the text parses", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + renderWithMantine( + , + ); + + const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement; + await user.type(jsonInput, "[[1,"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + await user.type(jsonInput, "2]"); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + }); + + it("reports a number this client cannot send exactly, and says so on the field", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + divisor: { type: "number", title: "Divisor" }, + }, + }; + renderWithMantine( + , + ); + + // Past MAX_SAFE_INTEGER the field reports no value rather than a rounded + // one, so — like the JSON editor — the text on screen would otherwise be + // submitted as an absent argument. + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input, "90071992547409910"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + expect(screen.getByText(/this field will be omitted/)).toBeInTheDocument(); + + await user.clear(input); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + expect( + screen.queryByText(/this field will be omitted/), + ).not.toBeInTheDocument(); + }); + + it("stays invalid while any one field is invalid", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + first: { type: "array", title: "First" }, + second: { type: "array", title: "Second" }, + }, + }; + renderWithMantine( + , + ); + + await user.type(screen.getByLabelText(/First/), "x"); + await user.type(screen.getByLabelText(/Second/), "y"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + // One of the two recovering is not enough. + await user.clear(screen.getByLabelText(/First/)); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + await user.clear(screen.getByLabelText(/Second/)); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + }); + + it("surfaces a nested object's invalid draft through the outer form", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + outer: { + type: "object", + title: "Outer", + properties: { + config: { type: "array", title: "Config" }, + }, + }, + }, + }; + renderWithMantine( + , + ); + + await user.type(screen.getByLabelText(/Config/), "x"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + }); + + it("clears a stale invalid draft when the form moves to another entity", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + const { rerender } = renderWithMantine( + , + ); + + await user.type(screen.getByLabelText(/Config/), "x"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + // Switching entities remounts the field, discarding its draft — so text + // typed for the previous one must not keep the new one blocked. + rerender( + , + ); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + }); + + it("reports valid when the form unmounts holding an invalid draft", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + const { unmount } = renderWithMantine( + , + ); + + await user.type(screen.getByLabelText(/Config/), "x"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + unmount(); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + }); + + it("renders without a validity callback", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + await user.type(screen.getByLabelText(/Config/), "x"); + expect(screen.getByText(/Not valid JSON/)).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 89a88c23..54e50b6e 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -8,7 +8,7 @@ import { Text, TextInput, } from "@mantine/core"; -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import { useValueChange } from "../../../hooks/useValueChange"; import type { @@ -169,13 +169,47 @@ function isSameJson(a: unknown, b: unknown): boolean { return serializeJson(a) === serializeJson(b); } +/** + * How a field tells the enclosing `SchemaForm` whether the text it is holding + * can be sent. Keyed by field name rather than closed over per field so the + * reporter is referentially stable, which is what keeps the effect below from + * re-subscribing on every render. + */ +type DraftValidityReporter = (fieldName: string, isValid: boolean) => void; + +/** + * Publish a field's draft validity to the enclosing `SchemaForm`. + * + * Reported from an effect rather than from the change handler because validity + * also moves when the *parent* replaces the value — a cleared form or a loaded + * example re-syncs the draft and makes invalid text valid again without the + * user touching the field. Reporting during that render instead would mean + * updating another component mid-render, which React rejects. + * + * The cleanup reports the field as valid again, so a field that goes away — + * unmounted with the panel, or remounted under a new `resetKey` when the form + * moves to another entity — cannot leave a stale draft blocking submission. + */ +function useDraftValidity( + fieldName: string, + isValid: boolean, + onValidityChange: DraftValidityReporter, +): void { + useEffect(() => { + onValidityChange(fieldName, isValid); + return () => onValidityChange(fieldName, true); + }, [fieldName, isValid, onValidityChange]); +} + interface SchemaJsonFieldProps { + fieldName: string; label: string; description?: string; withAsterisk: boolean; disabled: boolean; value: unknown; onChange: (value: unknown) => void; + onValidityChange: DraftValidityReporter; } /** @@ -200,18 +234,18 @@ interface SchemaJsonFieldProps { * it cannot send, and `undefined` is how "no value supplied" is spelled * everywhere else in this form. * - * That leaves invalid text and an empty field indistinguishable *to the - * parent*, so the field says so itself via `error`. A **required** field is - * already gated — its value is `undefined`, which `hasMissingRequiredFields` - * treats as missing — but an **optional** one would otherwise be dropped from - * the submission with no indication. Disabling Execute/Open/Submit on an - * invalid optional draft needs a validity channel out of this component and - * into all four callers, which is a wider change than this fix; tracked - * separately. + * That leaves invalid text and an empty field indistinguishable *to the value*, + * so the field reports the difference two other ways: it says so on itself via + * `error`, and it reports draft validity up through `onValidityChange` so the + * enclosing form's caller can disable Execute/Open App/Submit (#2020). Without + * the second channel an **optional** field's invalid text is simply dropped + * from a submission the user was allowed to make. */ function SchemaJsonField({ + fieldName, value, onChange, + onValidityChange, ...inputProps }: SchemaJsonFieldProps) { const [draft, setDraft] = useState(() => @@ -230,11 +264,11 @@ function SchemaJsonField({ // Text that is present but does not parse yields no value, so the field would // otherwise submit as absent while the user is still looking at what they - // typed. Saying so on the field is what keeps that from being silent; see the - // note on `SchemaJsonField` for the submit-gating half, which is not local to - // this component. + // typed. Saying so on the field keeps that from being silent; reporting it + // upward is what keeps it from being submittable. const hasInvalidDraft = draft.trim() !== "" && parseJsonDraft(draft) === undefined; + useDraftValidity(fieldName, !hasInvalidDraft, onValidityChange); return ( void; + onValidityChange: DraftValidityReporter; } /** @@ -289,8 +341,10 @@ interface SchemaNumberInputProps { * prop for the case it covers. */ function SchemaNumberInput({ + fieldName, value, onChange, + onValidityChange, ...inputProps }: SchemaNumberInputProps) { const [draft, setDraft] = useState(value ?? ""); @@ -301,10 +355,19 @@ function SchemaNumberInput({ } }); + // Same split as the JSON field: text this client cannot send reports no value, + // so it has to say so on the field and report it upward, or the argument is + // dropped from a submission the user was allowed to make (#2020). + const invalidDraft = hasInvalidNumericDraft(draft); + useDraftValidity(fieldName, !invalidDraft, onValidityChange); + return ( { setDraft(next); onChange(toNumericValue(next)); @@ -337,6 +400,21 @@ export interface SchemaFormProps { * substitute — callers rebuild it every render, so its identity is unstable. */ resetKey?: string; + /** + * Called with `true` while any field is holding text that cannot be sent — + * unparseable JSON, or a number this client cannot represent exactly. Both + * report their value as `undefined`, which is indistinguishable from an empty + * field once it reaches `values`, so a caller cannot derive this from the + * values it already has (#2020). + * + * Gate the submit action on it: `hasMissingRequiredFields` covers a field with + * *no* value, and this covers a field whose visible text produced none. An + * empty optional field is unaffected — it stays valid and submittable. + * + * The callback is read through a ref, so an inline closure is fine — it is + * called when the answer changes, not on every render. + */ + onValidityChange?: (hasInvalidDraft: boolean) => void; } function getDefaultValue(fieldSchema: InspectorFormSchema): unknown { @@ -362,10 +440,59 @@ export function SchemaForm({ onChange, disabled = false, resetKey, + onValidityChange, }: SchemaFormProps) { const properties = schema.properties ?? {}; const requiredFields = schema.required ?? []; + // The names of fields currently holding unsendable text. Held here rather + // than in each field because only the form sees them all, and only the form + // knows when the last one cleared. + const [invalidFields, setInvalidFields] = useState>( + () => new Set(), + ); + + // Stable so a field's reporting effect subscribes once, not per render. The + // updater returns the previous set unchanged when nothing moved, which is + // what makes a nested form's inline callback safe to call repeatedly. + const reportFieldValidity = useCallback( + (fieldName, isValid) => { + setInvalidFields((previous) => { + if (previous.has(fieldName) === !isValid) { + return previous; + } + const next = new Set(previous); + if (isValid) { + next.delete(fieldName); + } else { + next.add(fieldName); + } + return next; + }); + }, + [], + ); + + // Read through a ref so the callback's identity is not a dependency. It has + // to be one or the other, and a *stable* dependency is what this needs: a + // nested form is handed a fresh closure every render, and re-running the + // effect for that alone would toggle the parent's state (cleanup, then + // re-report), re-render this form, and loop forever. + const notifyValidity = useRef(onValidityChange); + useEffect(() => { + notifyValidity.current = onValidityChange; + }); + + // The cleanup mirrors the one in `useDraftValidity`: a form that is no longer + // rendered holds no drafts, so an unmounted nested form must not leave the + // outer one blocked. It runs in the same commit as the re-report, so the + // transient `false` is never rendered. + const hasInvalidDraft = invalidFields.size > 0; + useEffect(() => { + notifyValidity.current?.(hasInvalidDraft); + return () => notifyValidity.current?.(false); + }, [hasInvalidDraft]); + function handleFieldChange(fieldName: string, fieldValue: unknown) { onChange({ ...values, [fieldName]: fieldValue }); } @@ -452,6 +579,7 @@ export function SchemaForm({ // The only field holding local state, so the only one that has to be // remounted when `resetKey` says the form moved to another entity. key={resetKey === undefined ? fieldName : `${resetKey}:${fieldName}`} + fieldName={fieldName} label={label} description={description} withAsterisk={isRequired} @@ -463,6 +591,7 @@ export function SchemaForm({ // accepting a value the schema forbids. allowDecimal={fieldSchema.type === "number"} onChange={(val) => handleFieldChange(fieldName, val)} + onValidityChange={reportFieldValidity} /> ); } @@ -541,6 +670,11 @@ export function SchemaForm({ disabled={disabled} // Sub-fields belong to the same entity, so they reset with it. resetKey={resetKey} + // A nested form's invalid draft is the outer form's invalid draft, + // so it reports through the same channel under this field's name. + onValidityChange={(nestedInvalid) => + reportFieldValidity(fieldName, !nestedInvalid) + } /> @@ -553,12 +687,14 @@ export function SchemaForm({ // Holds local draft state, so — like the number field — it has to be // remounted when `resetKey` says the form moved to another entity. key={resetKey === undefined ? fieldName : `${resetKey}:${fieldName}`} + fieldName={fieldName} label={label} description={description} withAsterisk={isRequired} disabled={disabled} value={rawValue} onChange={(val) => handleFieldChange(fieldName, val)} + onValidityChange={reportFieldValidity} /> ); } diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx index feb434f2..cdad33ef 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx @@ -218,7 +218,13 @@ describe("ToolDetailPanel", () => { }); it("renders the Execute Tool button enabled when not executing", () => { - renderWithMantine(); + renderWithMantine( + , + ); const button = screen.getByRole("button", { name: "Execute Tool" }); expect(button).toBeInTheDocument(); expect(button).not.toBeDisabled(); @@ -231,6 +237,7 @@ describe("ToolDetailPanel", () => { , ); @@ -502,4 +509,61 @@ describe("ToolDetailPanel", () => { ).not.toBeInTheDocument(); }); }); + + // #2020: Execute used to be gated on `isExecuting` alone — this panel never + // called `hasMissingRequiredFields`, unlike the App and elicitation panels — + // so arguments that could not be sent were executed anyway and simply arrived + // absent at the server. + describe("submit gating (#2020)", () => { + const optionalJsonTool: Tool = { + name: "record_shipment", + inputSchema: { + type: "object", + // An array of no declared item type renders through the JSON editor, + // which is where a draft can fail to parse. + properties: { payload: { type: "array", title: "Payload" } }, + }, + }; + + it("disables Execute while a required argument has no value", () => { + renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "Execute Tool" }), + ).toBeDisabled(); + }); + + it("enables Execute once the required argument is filled", () => { + renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "Execute Tool" }), + ).not.toBeDisabled(); + }); + + it("disables Execute while an optional field holds text it cannot send", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + const execute = screen.getByRole("button", { name: "Execute Tool" }); + expect(execute).not.toBeDisabled(); + + // The draft never reaches `formValues` — the field reports `undefined` + // for text it cannot parse — so only the form's validity channel makes + // this visible to the gate. + const jsonInput = screen.getByLabelText(/Payload/); + await user.type(jsonInput, "x"); + expect(execute).toBeDisabled(); + + await user.clear(jsonInput); + expect(execute).not.toBeDisabled(); + }); + }); }); diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx index fbb6d26d..46b92cfa 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx @@ -19,7 +19,10 @@ import type { ToolAnnotations, } from "@modelcontextprotocol/client"; import { resolveDisplayLabel } from "../../../utils/toolUtils"; -import { toFormSchema } from "../../../utils/jsonUtils"; +import { + hasMissingRequiredFields, + toFormSchema, +} from "../../../utils/jsonUtils"; import { getMirroredHeaderParams } from "@inspector/core/json/xMcpHeader.js"; import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay"; @@ -213,6 +216,10 @@ export function ToolDetailPanel({ // a prior tool's hidden state doesn't carry over — mirrors how ToolsScreen // clears formValues on change. const [descriptionOpen, setDescriptionOpen] = useState(true); + // Text a field is holding that it could not turn into a value — unparseable + // JSON, or a number this client cannot send exactly. Reported by the form + // because the draft lives inside the field and never reaches `formValues`. + const [hasInvalidDraft, setHasInvalidDraft] = useState(false); const [prevToolName, setPrevToolName] = useState(name); if (name !== prevToolName) { setPrevToolName(name); @@ -245,6 +252,15 @@ export function ToolDetailPanel({ (taskSupport === "required" || ((taskSupport === "optional" || modernTasks) && runAsTask)); + // Two distinct reasons the arguments aren't sendable, and this panel used to + // check neither — Execute was gated on `isExecuting` alone, so a required + // argument left empty and a field full of unparseable text were both + // executable, and simply arrived at the server absent (#2020). + const executeDisabled = + isExecuting || + hasMissingRequiredFields(formSchema, formValues) || + hasInvalidDraft; + return ( @@ -321,6 +337,7 @@ export function ToolDetailPanel({ // so the form needs the tool name to drop another tool's // in-progress field text. See SchemaFormProps.resetKey. resetKey={name} + onValidityChange={setHasInvalidDraft} /> {progress && } @@ -341,7 +358,7 @@ export function ToolDetailPanel({ + + ); + } + const user = userEvent.setup(); + renderWithMantine(); + + const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement; + await user.click(screen.getByRole("button", { name: "load example" })); + expect(jsonInput.value).toBe('[\n "z"\n]'); + }); +}); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 54e50b6e..a849ab50 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -207,7 +207,14 @@ interface SchemaJsonFieldProps { description?: string; withAsterisk: boolean; disabled: boolean; + /** + * The field's own entry in the form's values — **not** run through + * `resolveValue`. A draft-holding field is handed the raw value and the + * schema default separately; see `defaultValue` for why. + */ value: unknown; + /** The schema `default`, used to seed the draft only. See `defaultValue`. */ + defaultValue: unknown; onChange: (value: unknown) => void; onValidityChange: DraftValidityReporter; } @@ -244,18 +251,29 @@ interface SchemaJsonFieldProps { function SchemaJsonField({ fieldName, value, + defaultValue, onChange, onValidityChange, ...inputProps }: SchemaJsonFieldProps) { - const [draft, setDraft] = useState(() => - value === undefined ? "" : serializeJson(value), - ); + const [draft, setDraft] = useState(() => { + const initial = value === undefined ? defaultValue : value; + return initial === undefined ? "" : serializeJson(initial); + }); // Re-sync only when the parent's value genuinely diverges from what the draft // parses to, which leaves an external reset (a cleared form, a loaded // example) working while an in-progress `[` — whose parse is `undefined`, // matching the `undefined` we just emitted — is left alone. + // + // The value compared here is the field's *raw* entry, never `resolveValue`'s + // default substitution (#2026). Unsendable text reports `undefined`, and a + // defaulted field resolves that straight back to its default — a real change + // from the previous value, so this would rewrite the draft to the default and + // revert the keystroke. Almost every edit passes through an unsendable state, + // which made a defaulted array or object argument uneditable. The default + // belongs to what the field *opens* with, seeded above, not to what is + // re-imposed while it is being typed into. useValueChange(value, (next) => { if (!isSameJson(parseJsonDraft(draft), next)) { setDraft(next === undefined ? "" : serializeJson(next)); @@ -309,7 +327,10 @@ interface SchemaNumberInputProps { description?: string; withAsterisk: boolean; disabled: boolean; + /** Raw, not `resolveValue`-substituted — see `SchemaJsonFieldProps.value`. */ value: number | undefined; + /** The schema `default`, used to seed the draft only (#2026). */ + defaultValue: number | undefined; min?: number; max?: number; allowDecimal: boolean; @@ -343,12 +364,19 @@ interface SchemaNumberInputProps { function SchemaNumberInput({ fieldName, value, + defaultValue, onChange, onValidityChange, ...inputProps }: SchemaNumberInputProps) { - const [draft, setDraft] = useState(value ?? ""); + const [draft, setDraft] = useState( + value ?? defaultValue ?? "", + ); + // Compared against the raw value, never the default-substituted one, for the + // reason spelled out on `SchemaJsonField` (#2026): clearing this box reports + // no value, which a defaulted field resolved straight back to its default — + // so the box refilled itself and the argument could not be emptied. useValueChange(value, (next) => { if (!Object.is(toNumericValue(draft), next)) { setDraft(next ?? ""); @@ -584,7 +612,9 @@ export function SchemaForm({ description={description} withAsterisk={isRequired} disabled={disabled} - value={rawValue as number | undefined} + // Raw and default kept apart, not pre-resolved (#2026). + value={values[fieldName] as number | undefined} + defaultValue={getDefaultValue(fieldSchema) as number | undefined} min={fieldSchema.minimum} max={fieldSchema.maximum} // An `integer` field rejects the decimal point outright rather than @@ -692,7 +722,9 @@ export function SchemaForm({ description={description} withAsterisk={isRequired} disabled={disabled} - value={rawValue} + // Raw and default kept apart, not pre-resolved (#2026). + value={values[fieldName]} + defaultValue={getDefaultValue(fieldSchema)} onChange={(val) => handleFieldChange(fieldName, val)} onValidityChange={reportFieldValidity} /> From c6edc13a9f47a4fb2f410d78781d348bbbb1a241 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 16 Aug 2026 00:44:53 -0400 Subject: [PATCH 50/58] docs: mark the JSON half of #2026 latent, not reproducible today MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: the code and test comments still said a defaulted array or object argument *was* uneditable. It would be, but not through today's seeding path — `collectSchemaDefaults` assigns `fieldSchema.default` itself, so the substitution returns the same reference and the re-sync never fires. It needs a structurally-equal but distinct value object, which parsed wire/deep-link values and rebuilt nested-object defaults produce, and which these tests construct. The number field has no such protection and did revert in the app. State the boundary in all three places, and stop claiming the distinct-object seed is how the Tools tab seeds the field, which it is not. Signed-off-by: cliffhall --- .../groups/SchemaForm/SchemaForm.test.tsx | 27 ++++++++++++++----- .../groups/SchemaForm/SchemaForm.tsx | 22 ++++++++++----- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 89ee31bb..e963ff8c 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1651,10 +1651,22 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => { // A schema `default` is what a field *opens* with, not a value re-imposed on // every unsendable draft. Unsendable text reports `undefined` by design, and -// `resolveValue` used to turn that straight back into the default — a real -// change from the previous value, so the draft/value re-sync rewrote the box -// and reverted the keystroke. Almost every edit passes through an unsendable -// state, which left a defaulted array or object argument uneditable. +// `resolveValue` used to turn that straight back into the default — a change +// from the previous value, so the draft/value re-sync rewrote the box and +// reverted the keystroke. +// +// The two halves differed in how reachable they were, which the tests below +// mirror deliberately: +// +// - **The number field reverted in the app.** Numbers compare by value, so +// clearing a defaulted box (value `3` → resolved default `30`) always fired +// the re-sync and refilled itself. +// - **The JSON field was latent.** `collectSchemaDefaults` assigns +// `fieldSchema.default` itself, so a field seeded from the schema holds the +// *same reference* the substitution returns and nothing fires. It needs a +// structurally-equal but distinct value object to become observable — which +// is what parsed wire/deep-link values and rebuilt nested-object defaults +// produce, and what these tests construct. describe("SchemaForm defaulted fields (#2026)", () => { function DefaultHarness({ schema, @@ -1681,8 +1693,10 @@ describe("SchemaForm defaulted fields (#2026)", () => { it("keeps a keystroke typed into a defaulted JSON field", async () => { const user = userEvent.setup(); - // Seeded the way the Tools tab seeds it — from the schema's defaults, as a - // separate value than the schema's own. Sharing one reference hid this. + // Seeded with a value that is equal to the schema default but is not the + // same object — how values parsed off the wire or out of a deep link + // arrive. Sharing the reference (what `collectSchemaDefaults` produces) is + // what keeps this latent rather than what makes it safe. renderWithMantine( , ); @@ -1697,6 +1711,7 @@ describe("SchemaForm defaulted fields (#2026)", () => { it("lets a defaulted JSON field be edited to a new value", async () => { const user = userEvent.setup(); + // Distinct-object seed again, for the reason above. renderWithMantine( , ); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index a849ab50..53edeeca 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -268,12 +268,22 @@ function SchemaJsonField({ // // The value compared here is the field's *raw* entry, never `resolveValue`'s // default substitution (#2026). Unsendable text reports `undefined`, and a - // defaulted field resolves that straight back to its default — a real change - // from the previous value, so this would rewrite the draft to the default and - // revert the keystroke. Almost every edit passes through an unsendable state, - // which made a defaulted array or object argument uneditable. The default - // belongs to what the field *opens* with, seeded above, not to what is - // re-imposed while it is being typed into. + // defaulted field resolves that straight back to its default — a change from + // the previous value, so comparing against the resolved one would rewrite the + // draft to the default and revert the keystroke. Almost every edit passes + // through an unsendable state, so a defaulted array or object argument would + // be uneditable. + // + // Would be, not was: this is latent under today's seeding, where + // `collectSchemaDefaults` assigns `fieldSchema.default` itself, so the + // substitution returns the *same reference* and `useValueChange` never fires. + // It goes live as soon as the value is a structurally-equal but distinct + // object — parsed from the wire or a deep link, or a nested-object default, + // which `collectSchemaDefaults` rebuilds per call. `SchemaNumberInput` has no + // such accidental protection, since a number compares by value. + // + // Either way the default belongs to what the field *opens* with, seeded + // above, not to what is re-imposed while it is being typed into. useValueChange(value, (next) => { if (!isSameJson(parseJsonDraft(draft), next)) { setDraft(next === undefined ? "" : serializeJson(next)); From d3b70a6329bee2c65cd9c25d01fa7237537c3c9e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 16 Aug 2026 00:53:16 -0400 Subject: [PATCH 51/58] fix(web): keep an explicit null out of the number field's default fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: seeding with `value ?? defaultValue` also substitutes the default when the raw value is an explicit `null` — a nullable number field the user cleared, or one the server sent — where `resolveValue` previously kept the null and the box rendered empty. A nullable schema with a non-null default would have opened showing that default over a real value. Test the `undefined` case explicitly, as `SchemaJsonField` already does, and pin it: the new test fails with `'30'` where `''` belongs. Signed-off-by: cliffhall --- .../groups/SchemaForm/SchemaForm.test.tsx | 19 +++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 7 ++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index e963ff8c..3c7193df 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1742,6 +1742,25 @@ describe("SchemaForm defaulted fields (#2026)", () => { expect(n.value).toBe(""); }); + // An explicit `null` is a value, not an absent one, so a non-null default + // must not be substituted for it. Reachable for a nullable number field the + // user cleared, or one the server sent as null. + it("shows an explicit null as empty, not as the default", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + n: { + title: "N", + anyOf: [{ type: "integer" }, { type: "null" }], + default: 30, + }, + }, + }; + renderWithMantine(); + + expect((screen.getByLabelText(/^N$/) as HTMLInputElement).value).toBe(""); + }); + it("still re-syncs a defaulted field when the value changes externally", async () => { function ExternalHarness() { const [values, setValues] = useState>({ diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 53edeeca..acd59ca7 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -379,8 +379,13 @@ function SchemaNumberInput({ onValidityChange, ...inputProps }: SchemaNumberInputProps) { + // `undefined` means "no value supplied", which is what the default is for. + // An explicit `null` is a value — a nullable number the user cleared, or one + // the server sent — so it must not be overwritten by a non-null default; it + // displays as the empty box `resolveValue` produced before. Hence the + // `undefined` test rather than `??`, matching `SchemaJsonField`. const [draft, setDraft] = useState( - value ?? defaultValue ?? "", + (value === undefined ? defaultValue : value) ?? "", ); // Compared against the raw value, never the default-substituted one, for the From 27fe15976fb273bf83792299a1a1803a6f35f168 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 16 Aug 2026 01:00:48 -0400 Subject: [PATCH 52/58] fix(web): admit null in the number field's value and default prop types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: a nullable number schema really does produce `null` for both the raw value and the schema default, but the props declared only `number | undefined` and the call site cast the null away. The runtime already handled it — the initializer's `?? ""` exists for exactly that — so the types were the part that was wrong, and a cast hiding it is how a future null bug goes unseen by the compiler. Widen both to `number | null | undefined` and drop the narrowing casts, keeping the distinction the initializer encodes: `undefined` takes the default, `null` is a value and renders as an empty box. Signed-off-by: cliffhall --- .../groups/SchemaForm/SchemaForm.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index acd59ca7..46103ba0 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -337,10 +337,17 @@ interface SchemaNumberInputProps { description?: string; withAsterisk: boolean; disabled: boolean; - /** Raw, not `resolveValue`-substituted — see `SchemaJsonFieldProps.value`. */ - value: number | undefined; - /** The schema `default`, used to seed the draft only (#2026). */ - defaultValue: number | undefined; + /** + * Raw, not `resolveValue`-substituted — see `SchemaJsonFieldProps.value`. + * + * `null` is admitted alongside `undefined` because a nullable number schema + * produces it, and the two are not interchangeable here: `undefined` means + * "no value supplied" and takes `defaultValue`, while `null` is a value and + * displays as an empty box. + */ + value: number | null | undefined; + /** The schema `default`, used to seed the draft only (#2026). May be `null`. */ + defaultValue: number | null | undefined; min?: number; max?: number; allowDecimal: boolean; @@ -627,9 +634,12 @@ export function SchemaForm({ description={description} withAsterisk={isRequired} disabled={disabled} - // Raw and default kept apart, not pre-resolved (#2026). - value={values[fieldName] as number | undefined} - defaultValue={getDefaultValue(fieldSchema) as number | undefined} + // Raw and default kept apart, not pre-resolved (#2026). Both admit + // `null`, which a nullable number schema really produces. + value={values[fieldName] as number | null | undefined} + defaultValue={ + getDefaultValue(fieldSchema) as number | null | undefined + } min={fieldSchema.minimum} max={fieldSchema.maximum} // An `integer` field rejects the decimal point outright rather than From a945fb71c6e44f3101de602317c884ebde3de82d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 16 Aug 2026 01:08:40 -0400 Subject: [PATCH 53/58] docs: correct how a null reaches the number field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: the comments offered "a nullable number the user cleared" as a way to get an explicit `null` into this field. Clearing the box reports `undefined` — `toNumericValue("")` says so, and an existing test pins it — so the only way `null` arrives is from parent state: a value received from the server, restored from a deep link, or written by a caller for a nullable schema. Say that in all three places instead. Signed-off-by: cliffhall --- .../groups/SchemaForm/SchemaForm.test.tsx | 7 +++++-- .../groups/SchemaForm/SchemaForm.tsx | 19 ++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 3c7193df..b1d3182f 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1743,8 +1743,11 @@ describe("SchemaForm defaulted fields (#2026)", () => { }); // An explicit `null` is a value, not an absent one, so a non-null default - // must not be substituted for it. Reachable for a nullable number field the - // user cleared, or one the server sent as null. + // must not be substituted for it. Note the field itself never emits `null` — + // clearing it reports `undefined` (pinned by "passes undefined to onChange + // when a number field is cleared") — so this arrives from parent state: a + // value received from the server, restored from a deep link, or written by a + // caller for a nullable schema. it("shows an explicit null as empty, not as the default", () => { const schema: InspectorFormSchema = { type: "object", diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 46103ba0..152fe40f 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -341,9 +341,9 @@ interface SchemaNumberInputProps { * Raw, not `resolveValue`-substituted — see `SchemaJsonFieldProps.value`. * * `null` is admitted alongside `undefined` because a nullable number schema - * produces it, and the two are not interchangeable here: `undefined` means - * "no value supplied" and takes `defaultValue`, while `null` is a value and - * displays as an empty box. + * produces it — by way of parent state, never by clearing the box — and the + * two are not interchangeable here: `undefined` means "no value supplied" + * and takes `defaultValue`, while `null` is a value and displays empty. */ value: number | null | undefined; /** The schema `default`, used to seed the draft only (#2026). May be `null`. */ @@ -387,10 +387,15 @@ function SchemaNumberInput({ ...inputProps }: SchemaNumberInputProps) { // `undefined` means "no value supplied", which is what the default is for. - // An explicit `null` is a value — a nullable number the user cleared, or one - // the server sent — so it must not be overwritten by a non-null default; it - // displays as the empty box `resolveValue` produced before. Hence the - // `undefined` test rather than `??`, matching `SchemaJsonField`. + // An explicit `null` is a value, so it must not be overwritten by a non-null + // default; it displays as the empty box `resolveValue` produced before. + // Hence the `undefined` test rather than `??`, matching `SchemaJsonField`. + // + // Clearing the box does *not* produce that `null` — `toNumericValue("")` + // reports `undefined`, which is the behavior the "passes undefined to + // onChange when a number field is cleared" test pins. A `null` reaches this + // field only from parent state: a value received from the server, restored + // from a deep link, or written by a caller for a nullable schema. const [draft, setDraft] = useState( (value === undefined ? defaultValue : value) ?? "", ); From ae0e9d53a312fb04ef80aee5f6c515c05c3116a7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 16 Aug 2026 12:15:41 -0400 Subject: [PATCH 54/58] test(tui): settle input handlers at the keypress sites too, and pin the contract Extends the previous commit's fix rather than changing it. `waitUntil` now settles, but the same window is open wherever a key is written without having just polled: `press()` reaches its `stdin.write` on a turn that may still have React's passive-effect flush queued (straight after a plain `tick`, or after an earlier key committed a render), and `mount()`'s absorbing keypress lands one tick after the mount commit. Both now `await settleInputHandlers()` before writing, so "frame visible" implies "input handlers armed" at every dispatch point, not only after a frame wait. Adds a `test helpers` case that pins the contract: a `setImmediate` sentinel stands in for the pending effect flush and must have run by the time `waitUntil` resolves. Verified it fails when the yield is removed, so a future regression surfaces here instead of as a differently-named flaky OAuth test under coverage instrumentation. Signed-off-by: cliffhall --- clients/tui/__tests__/App.test.tsx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 9025b52e..1efcd408 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -507,6 +507,9 @@ function renderApp(servers: Record) { async function mount(servers: Record) { const r = renderApp(servers); await tick(); + // The mount commit's effect flush can still be queued behind this tick, and + // this write is the one that absorbs the dropped first keypress. + await settleInputHandlers(); r.stdin.write("x"); await tick(); return r; @@ -532,6 +535,11 @@ const ENTER = "\r"; */ async function press(r: RenderResult, keys: string[]) { for (const k of keys) { + // Same hazard `waitUntil` guards against, at the other end: a caller can + // reach here on a turn that still has React's passive-effect flush queued + // (e.g. straight after a plain `tick`, or after an earlier key committed a + // render), so settle before every write rather than only after a poll. + await settleInputHandlers(); r.stdin.write(k); await tick(); await tick(); @@ -646,6 +654,23 @@ afterEach(() => { while (mounted.length) mounted.pop()?.unmount(); }); +// Pins the synchronization contract the OAuth step-up assertions depend on +// (#1942). The `setImmediate` sentinel below stands in for React's pending +// passive-effect flush — the turn where ink re-arms `useInput`. If `waitUntil` +// ever returns without yielding a check-phase turn, the sentinel has not run +// and this fails, instead of the regression resurfacing as a differently-named +// flaky OAuth test under coverage instrumentation. +describe("test helpers", () => { + it("waitUntil settles input handlers before resolving", async () => { + let flushed = false; + setImmediate(() => { + flushed = true; + }); + await waitUntil(() => true); + expect(flushed).toBe(true); + }); +}); + describe("App (foundation)", () => { it("renders the server list with the MCP Servers header", async () => { const r = renderApp(stdioServer()); From 7f9be24bf3f1f34516ec0452b6f8c5fd18d11359 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 16 Aug 2026 12:17:15 -0400 Subject: [PATCH 55/58] chore(scripts): derive dep-lockstep candidates from what enters each tsc program MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verify:dep-lockstep` derived its candidate set from the packages the shared first-party sources named *directly*, so a package whose declarations reach a program only through another package's `.d.ts` was invisible to it — it could skew across installs and recreate the two-copy blow-up #1896 was about while the guard stayed green. `@modelcontextprotocol/sdk` was the live example: never written in first-party code, yet 16 of its `.d.ts` files land in clients/web's test program. Replace the derivation with the measured one: list every client tsconfig project with `tsc --listFilesOnly`, map each resolved `node_modules` file to its owning install and package, and keep the packages that reach ONE program from TWO installs. That is exactly the set that can put two structurally distinct copies of a type in front of one checker. The rest of the guard is unchanged — top-level lockfile comparison, deny-by-default, `TOLERATED_SKEW` as a name allowlist scoped to within-a-major. The listing machinery moves to `scripts/lib/tsc-program.mjs`, shared with `verify:typecheck-coverage`, so the two guards can't disagree about what a program contains. It is deliberately not cached to disk between the two processes: a stale fingerprint would make a guard measure a program that no longer exists and pass on a real miss. The second pass costs ~14s. `TOLERATED_SKEW` is now empty. `jose` and `@modelcontextprotocol/ext-apps` are root-only since #1970 and cannot skew; `react` (whose types are `@types/react`, resolved from one install) and `hono` never reach one program from two installs. Each would be a rationale for a skew that cannot occur. Closes #1965 Signed-off-by: cliffhall --- .github/copilot-instructions.md | 2 +- AGENTS.md | 11 +- README.md | 6 +- scripts/lib/tsc-program.mjs | 382 ++++++++++++++++++ scripts/lib/tsc-program.test.mjs | 259 ++++++++++++ scripts/verify-dep-lockstep.main.test.mjs | 250 +++++++++--- scripts/verify-dep-lockstep.mjs | 440 +++++++++------------ scripts/verify-dep-lockstep.test.mjs | 281 ++----------- scripts/verify-typecheck-coverage.mjs | 231 +---------- scripts/verify-typecheck-coverage.test.mjs | 142 +------ 10 files changed, 1070 insertions(+), 934 deletions(-) create mode 100644 scripts/lib/tsc-program.mjs create mode 100644 scripts/lib/tsc-program.test.mjs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0bc70bfc..32122205 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -100,7 +100,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo ## Gates and PR hygiene - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. -- **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR bumping a package that the shared sources import should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency. +- **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this — it derives its candidates from what each `tsc` program actually resolves (`tsc --listFilesOnly`, keeping packages that reach one program from two installs), so a package reached only through another package's `.d.ts` counts too (#1965) — so a PR bumping a package the shared sources pull in should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency. - **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. - **Every PR references an issue**, first body line `Closes #`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. diff --git a/AGENTS.md b/AGENTS.md index de3945eb..eac73b7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,8 +118,11 @@ v2/main/ ├── scripts/ # Root build/verify tooling: install-clients.mjs (the │ # postinstall cascade), the smoke-*.mjs runners, │ # verify-build-gate / verify-format-coverage / -│ # verify-typecheck-coverage, pack-and-verify.mjs, -│ # and lib/ shared helpers. Prettier-gated via +│ # verify-typecheck-coverage / verify-dep-lockstep, +│ # pack-and-verify.mjs, and lib/ shared helpers +│ # (tsc-program.mjs is the `tsc --listFilesOnly` +│ # measurement both coverage guards read a program +│ # through — #1965). Prettier-gated via │ # `format:check:scripts`; its own pure parsers are │ # unit-tested by `npm run test:scripts` (node --test). ├── specification/ # Build specification @@ -686,12 +689,12 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no dependency *directly imported* by the shared sources resolves to two different versions across installs; transitive-only declarations are a known boundary, #1965), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no dependency that reaches a single `tsc` program from two installs resolves to two different versions across them), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib _resolution_ options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is _wired_ (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently _skips_ a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the _global_ union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from the packages the shared first-party TypeScript imports — `core/`, `test-servers/src`, and the individually-named `vitest.shared.mts` (root-owned and imported by every client's vitest config) — so a new shared dependency is covered without editing the guard. It reads the committed lockfiles' **top-level** `node_modules/` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and fails, **deny-by-default**, on any candidate held at two versions across installs. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; `react`, `hono`, `jose`, and `@modelcontextprotocol/ext-apps` are listed today. **Being listed is not a blanket exemption** — it tolerates skew only *within a major version*, since a rationale about patch-level differences says nothing about a React 18-vs-19 split, where the type surface itself changes; a cross-major skew fails even for a listed package. **When bumping a dependency that the shared sources import, bump it in every install that declares it** — that's the root plus whichever clients list it, not all four unconditionally (launcher declares no zod, for instance, and a package absent from an install can't skew, so the guard ignores it there). Don't add a dependency to a client just to satisfy this. Its pure helpers are unit-tested via `test:scripts`, and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. + - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from **what actually enters each `tsc` program** (#1965) — every client tsconfig project is listed with `tsc --listFilesOnly` through the shared `scripts/lib/tsc-program.mjs` helper (the same machinery `verify:typecheck-coverage` reads a program through, so the two guards can't disagree about what one contains), each resolved `node_modules` file is mapped to its owning install and package, and a package reaching **one** program from **two** installs is a candidate. That is precisely the set that can put two structurally-distinct copies of a type in front of one checker — and it needs no editing when a new dependency arrives. It replaced a derivation that read the packages the shared sources named *directly*, which could not see one whose declarations arrive only through another package's `.d.ts`: `@modelcontextprotocol/sdk` is never written in first-party code (the shared sources import the split `@modelcontextprotocol/client|core|…`) yet 16 of its `.d.ts` files land in `clients/web`'s test program, so a second copy under `clients/web/node_modules` skewed unseen. Two properties are worth knowing: a package present in two installs but reached from only one in a given program is correctly **not** a candidate, and TypeScript's package-identity redirect collapses two copies at the *same* name@version (so an aligned package's own transitive dependencies load once) — the redirect stops applying the moment they skew, which is exactly when the guard needs to see both. Versions themselves still come from the committed lockfiles' **top-level** `node_modules/` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and any candidate held at two versions fails, **deny-by-default**. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; it is **empty today** — the four names it used to carry (`react`, `hono`, `jose`, `@modelcontextprotocol/ext-apps`) were admitted under the old derivation and none is a candidate under this one, so each would be a rationale for a skew that cannot occur. **Being listed is not a blanket exemption** — it tolerates skew only *within a major version*, since a rationale about patch-level differences says nothing about a React 18-vs-19 split, where the type surface itself changes; a cross-major skew fails even for a listed package. **When bumping a dependency that the shared sources pull in, bump it in every install that declares it** — that's the root plus whichever clients list it, not all four unconditionally (launcher declares no zod, for instance, and a package absent from an install can't skew, so the guard ignores it there). Don't add a dependency to a client just to satisfy this. Its pure helpers are unit-tested via `test:scripts` (its own, plus `scripts/lib/tsc-program.test.mjs` for the shared derivation), and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. It runs the same `tsc --listFilesOnly` pass its sibling does, in its own process, costing ~14s: the listing is deliberately **not** cached to disk between the two, because a fingerprint that missed an input would make a guard measure a program that no longer exists and pass on a real miss. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. diff --git a/README.md b/README.md index 467a4fc0..0bca3291 100644 --- a/README.md +++ b/README.md @@ -294,14 +294,14 @@ Each client self-validates from its own folder; the root scripts chain them. The | Script | What it does | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency the shared sources directly import skews across installs) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency reaching one `tsc` program from two installs skews across them) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | | `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | -| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from what those shared sources import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist — and an allowlisted package is tolerated only *within a major version*. Runs in `validate`. | +| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Compares those against the committed lockfiles' top-level entries and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist — empty today — and an allowlisted package is tolerated only *within a major version*. Runs in `validate`. | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/scripts/lib/tsc-program.mjs b/scripts/lib/tsc-program.mjs new file mode 100644 index 00000000..189c13df --- /dev/null +++ b/scripts/lib/tsc-program.mjs @@ -0,0 +1,382 @@ +// What a `tsc` project actually pulls into its program — the shared measurement +// behind two guards, so the two can never disagree about what a program holds. +// +// • `verify:typecheck-coverage` (#1791) asks which FIRST-PARTY files land in a +// program, to prove every tracked source file gets a `tsc` pass. +// • `verify:dep-lockstep` (#1965) asks which INSTALLED PACKAGES land in one, to +// find the packages that can appear twice — from two different installs — in +// a single program, which is the whole failure mode #1896 was about. +// +// Both answers come from the same `tsc --listFilesOnly` run: it reports every +// file the program resolves, including the ones reached only through another +// file's imports and the `.d.ts` files under `node_modules`. That is the accurate +// measure — a tsconfig's `include` names only the roots. +// +// The listing is memoized per (client, project) for the life of the process. It +// is deliberately NOT cached to disk across the two guard processes: a stale +// cache would make either guard measure a program that no longer exists and pass +// on a real miss — failing open, the one way these gates must never fail. The +// second pass costs ~14s (measured, #1965); that is cheaper than the class of +// bug a fingerprint that misses one input would introduce. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { reachableScripts, tokenize } from "./npm-scripts.mjs"; + +export const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); + +// Match `tsc` by token basename so a path-invoked binary (`node_modules/.bin/ +// tsc`, `./node_modules/.bin/tsc.cmd`) counts, not just the bare `tsc` token. +export const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); + +// A flag that makes a `tsc` pass list files without type-checking them (so it +// gates nothing). Case-insensitive — tsc's own option parsing is. +export const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t); + +/** + * The tsconfig projects a client's `typecheck` names, harvested from **every** + * script reachable from `typecheck` (not just the one string) so a delegating + * `typecheck` (`npm run typecheck:src && …`) still counts — matching how + * `verify-format-coverage.mjs` harvests globs across reachable scripts. Splits + * each script on `&&`/`||`/`;` so a flag on one command doesn't leak onto + * another. Each `tsc` command's project comes from `-p`/`--project` (or a + * `-b`/`--build` path); a `tsc` command with **no** project flag resolves the + * implicit `./tsconfig.json` (tsc's own default), so that idiomatic form counts + * too. Returns `{ projects, neutered }`: `neutered` names any project whose own + * command carries `--noCheck`/`--nocheck` or `--listFilesOnly` (matched + * case-insensitively — tsc's option parsing is) — a pass that lists files + * without type-checking them, which would otherwise satisfy the typecheck guard + * while checking nothing. + * + * A harvested `tsc -b` **solution config** (`"files": []` + `references`) lists + * nothing itself; {@link resolveLeafProjects} expands it to its references. + * + * Minor limitations, all unreachable with the plain `-p --noEmit` passes here: + * the implicit-`./tsconfig.json` fallback assumes **no file operands** (`tsc + * ` ignores the config and checks only that file, but would be credited + * the whole config's file list); the `--noCheck`/`--listFilesOnly` detection + * ignores a following boolean, so the contrived explicit `--noCheck false` + * (checking *on*) is still treated as disabling; and the `&&`/`||`/`;` split + * runs before tokenizing, so a quoted operator inside an arg would split + * mid-token (project paths carry none of those). + */ +export function typecheckProjects(scripts) { + const projects = []; + const neutered = []; + const isFlag = (t) => t.startsWith("-"); + const isProjectFlag = (t) => ["-p", "--project", "-b", "--build"].includes(t); + for (const name of reachableScripts(scripts, "typecheck")) { + const cmd = scripts?.[name]; + if (typeof cmd !== "string") continue; + for (const segment of cmd.split(/&&|\|\||;/)) { + const tokens = tokenize(segment); + if (!tokens.some(isTsc)) continue; // only tsc commands name projects + const disabling = tokens.find(isDisablingFlag); + // A project path follows `-p`/`--project`/`-b`/`--build`; a tsc command + // with none uses the implicit `./tsconfig.json` (tsc's own default). + const named = []; + for (let i = 0; i < tokens.length; i++) + if (isProjectFlag(tokens[i]) && tokens[i + 1] && !isFlag(tokens[i + 1])) + named.push(tokens[i + 1]); + if (named.length === 0) named.push("tsconfig.json"); + for (const project of named) { + if (disabling) neutered.push({ project, flag: disabling }); + else projects.push(project); + } + } + } + return { projects, neutered }; +} + +/** + * The `references` paths declared in a tsconfig's raw text (a `tsc -b` solution + * config), or `[]` if it has none / isn't parseable. Paths are as written + * (relative to that tsconfig's own directory). + */ +export function parseTsconfigReferences(raw) { + try { + // Tolerate JSONC — block AND line comments + trailing commas (tsconfig + // allows all; block comments are in fact the style of every other tsconfig + // here). Block comments are stripped first so a `//` inside one doesn't + // survive; a `//` inside a string value (e.g. an `https://` URL) is a + // theoretical false strip this guard's tsconfigs never hit. + const cfg = JSON.parse( + raw + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, "") + .replace(/,(\s*[}\]])/g, "$1"), + ); + return Array.isArray(cfg.references) + ? cfg.references.map((r) => r?.path).filter((p) => typeof p === "string") + : []; + } catch { + return []; + } +} + +export function tsconfigReferences(tsconfigRel) { + try { + return parseTsconfigReferences( + readFileSync(path.join(repoRoot, tsconfigRel), "utf8"), + ); + } catch { + return []; // unreadable file (e.g. a directory / missing path) + } +} + +/** + * The `references` in a client's root `tsconfig.json`. Non-empty for a `tsc -b` + * client (like `clients/web`, which has no `typecheck` script) — the guards + * enroll such a client through these instead of exempting the whole tree. + */ +export function clientTsconfigReferences(clientDir) { + return tsconfigReferences(path.posix.join(clientDir, "tsconfig.json")); +} + +/** + * The repo-relative tsconfig FILE a `clientDir`-relative `project` entry names. + * A directory-form entry (`{ "path": "./packages/a" }`, or `tsc -p src`) means + * `/tsconfig.json` — tsc's own rule. + */ +export function projectConfigFile(clientDir, project) { + const projectRel = path.posix.join(clientDir, project); + return projectRel.endsWith(".json") + ? projectRel + : path.posix.join(projectRel, "tsconfig.json"); +} + +/** + * A `references` entry `ref` (written relative to `fromConfigFile`'s own + * directory) as a `clientDir`-relative project path, the form the rest of the + * graph walk uses. + */ +export function refToProject(clientDir, fromConfigFile, ref) { + return path.posix.relative( + clientDir, + path.posix.join(path.posix.dirname(fromConfigFile), ref), + ); +} + +/** + * The leaf tsconfig projects `project` resolves to (paths relative to + * `clientDir`): itself if it lists files (or has no `references`), else its + * `references` expanded recursively. A `tsc -b` **solution config** (`{"files": + * [], "references": […]}`) lists nothing under `--listFilesOnly`, so this is how + * it's reduced to the real projects — and doing it here (not inside a single + * caller) is what lets every consumer follow the same graph. + */ +export function resolveLeafProjects(clientDir, project, seen = new Set()) { + if (seen.has(project)) return []; + seen.add(project); + // Lists first-party files → a real leaf. (An empty set is a solution config, + // or a config that errored — either way the reference expansion below is the + // right next step: a broken config yields no references either.) + if (projectSourceFiles(clientDir, project).size > 0) return [project]; + const configFile = projectConfigFile(clientDir, project); + const refs = tsconfigReferences(configFile); + if (refs.length === 0) return [project]; // no files, no refs — itself + return refs.flatMap((ref) => + resolveLeafProjects( + clientDir, + refToProject(clientDir, configFile, ref), + seen, + ), + ); +} + +/** + * Every file ONE project's program resolves, as absolute POSIX paths and with no + * filtering at all — first-party sources, `node_modules` declarations, and the + * out-of-repo `lib.*.d.ts` — plus the config diagnostic if `tsc` exited + * non-zero. The two consumers want different slices of the files, so the slicing + * happens in {@link projectSourceFiles} / {@link projectPackageFiles} rather than + * here; they differ on the `error` too, which is why it is reported rather than + * only warned about. Memoized per (client, project): the same project is listed + * by `resolveLeafProjects` and again by whichever slice the caller asks for. + */ +const listingCache = new Map(); +export function rawProjectListing(clientDir, project) { + const key = `${clientDir}|${project}`; + const cached = listingCache.get(key); + if (cached) return cached; + let stdout; + let error = null; + try { + stdout = execFileSync( + "npx", + ["--no-install", "tsc", "-p", project, "--listFilesOnly"], + { + cwd: path.join(repoRoot, clientDir), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 1 << 28, + }, + ); + } catch (err) { + // `--listFilesOnly` doesn't type-check, but a config error (an unreadable or + // malformed tsconfig) still exits non-zero while printing the resolved file + // list; keep stdout so a broken config doesn't mask what the callers measure. + // Echo the diagnostic — the guards run before any client's own `typecheck`, + // so this is the first place a bad `-p` config surfaces, and without the + // reason the resulting report is misleading. tsc prints config errors + // (`error TS…`) to stdout, so scan both streams for them. + stdout = typeof err.stdout === "string" ? err.stdout : ""; + const streams = + stdout + "\n" + (typeof err.stderr === "string" ? err.stderr : ""); + error = streams + .split("\n") + .filter((l) => /error TS\d+/.test(l)) + .join("\n") + .trim(); + console.warn( + `tsc -p ${project} (in ${clientDir}) exited non-zero:\n${error || "(no diagnostic captured)"}\n`, + ); + // An empty diagnostic still has to read as failure downstream, so the + // caller sees a string either way — never `""`. + error ||= "(no diagnostic captured)"; + } + const files = stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .map((abs) => abs.split(path.sep).join("/")); + const listing = { files, error }; + listingCache.set(key, listing); + return listing; +} + +/** + * The `tsc` diagnostic for a project whose listing exited non-zero, or null. A + * failed config still prints a file list — a partial, wrong one — so a consumer + * that must not measure a program it couldn't resolve has to ask. + */ +export function projectListingError(clientDir, project) { + return rawProjectListing(clientDir, project).error; +} + +/** + * Whether a repo-relative path is an installed file rather than a first-party + * one. Matched by path SEGMENT, not substring, so a first-party directory whose + * name merely embeds the word (`core/node_modules_fixtures/`) is not mistaken + * for an install — the same segment rule {@link classifyModulePath} applies. + */ +const isInstalledPath = (rel) => rel.split("/").includes("node_modules"); + +/** + * Repo-relative POSIX paths of the first-party files ONE project (no reference + * expansion) typechecks. Absolute paths outside the repo root (`lib.d.ts`) and + * anything under `node_modules` are dropped; the aliased `core/` + + * `test-servers/` sources stay in the set but are harmless — the set is only ever + * queried with in-repo paths. + */ +export function projectSourceFiles(clientDir, project) { + const covered = new Set(); + for (const abs of rawProjectListing(clientDir, project).files) { + const rel = path.relative(repoRoot, abs).split(path.sep).join("/"); + if (rel.startsWith("..") || isInstalledPath(rel)) continue; + covered.add(rel); + } + return covered; +} + +/** + * Repo-relative POSIX paths of the INSTALLED files ONE project's program + * resolves — everything under some `node_modules` inside the repo. Out-of-repo + * paths are dropped: an absolute `lib.d.ts` under the toolchain isn't an install + * this repo can align. + */ +export function projectPackageFiles(clientDir, project) { + const covered = new Set(); + for (const abs of rawProjectListing(clientDir, project).files) { + const rel = path.relative(repoRoot, abs).split(path.sep).join("/"); + if (rel.startsWith("..") || !isInstalledPath(rel)) continue; + covered.add(rel); + } + return covered; +} + +/** + * The install a repo-relative `node_modules` path belongs to, and the package it + * holds — `{ installRoot, name }`, or null for a path that names neither. + * + * The install root is the prefix before the **first** `node_modules` segment + * (`.` for the repo root, `clients/web` for a client install), while the package + * name comes from after the **last** one. That asymmetry is deliberate: a + * *nested* `node_modules/a/node_modules/b` is npm resolving a transitive + * conflict **inside one install**, which is routine and not a cross-install + * skew — the same reason the lockfile comparison reads only top-level entries. + * Folding it onto its outermost install is what keeps the candidate set the + * handful of genuinely install-crossing packages instead of every transitive + * duplicate in the tree. + */ +export function classifyModulePath(rel) { + const parts = rel.split("/"); + const first = parts.indexOf("node_modules"); + if (first === -1) return null; + const last = parts.lastIndexOf("node_modules"); + const head = parts[last + 1]; + // `node_modules/.bin/…`, `node_modules/.package-lock.json`: npm's own + // bookkeeping, not a package. + if (!head || head.startsWith(".")) return null; + const name = head.startsWith("@") + ? parts[last + 2] && `${head}/${parts[last + 2]}` + : head; + if (!name) return null; // a bare `node_modules/@scope` path names no package + return { + installRoot: first === 0 ? "." : parts.slice(0, first).join("/"), + name, + }; +} + +/** + * The packages that enter a SINGLE program from two different installs — the + * only ones whose version skew can put two structurally-distinct copies of one + * type in front of the type checker. + * + * `programs` is an iterable of `{ label, files }`, one per leaf tsconfig project, + * `files` being repo-relative `node_modules` paths. Returns a Map keyed by + * package name, each value `{ installRoots, programs }` naming where it was seen + * — the diagnostic a failure needs. + * + * The two-install test is applied **within one program**, not across the run: a + * package reached from the root install in web's program and from the client + * install in cli's program is two copies in two *separate* type checks, which + * nothing has to relate. + * + * Note what TypeScript's own package-identity dedup does to this, because it + * makes the measure self-correcting rather than leaky. Two copies of a package + * at the **same** name@version are collapsed into a redirect, and a redirected + * `.d.ts` does not resolve its own imports — so an aligned package's transitive + * dependencies are loaded once, from one install, and never become candidates. + * That is the right answer, not a miss: one copy in the program is one copy for + * the checker to instantiate, whatever the tree looks like on disk. The moment + * the pair skews, the redirect stops applying, both copies enter the program, + * and the package becomes a candidate — which is exactly when it matters. + */ +export function crossInstallPackages(programs) { + const found = new Map(); + for (const { label, files } of programs) { + const roots = new Map(); // name -> Set(installRoot), within THIS program + for (const rel of files) { + const hit = classifyModulePath(rel); + if (!hit) continue; + if (!roots.has(hit.name)) roots.set(hit.name, new Set()); + roots.get(hit.name).add(hit.installRoot); + } + for (const [name, installRoots] of roots) { + if (installRoots.size < 2) continue; + if (!found.has(name)) + found.set(name, { installRoots: new Set(), programs: new Set() }); + const entry = found.get(name); + for (const root of installRoots) entry.installRoots.add(root); + entry.programs.add(label); + } + } + return found; +} diff --git a/scripts/lib/tsc-program.test.mjs b/scripts/lib/tsc-program.test.mjs new file mode 100644 index 00000000..fa9bb9e1 --- /dev/null +++ b/scripts/lib/tsc-program.test.mjs @@ -0,0 +1,259 @@ +// Table-driven tests for the shared `tsc` program helpers — the machinery both +// `verify:typecheck-coverage` (#1791) and `verify:dep-lockstep` (#1965) read a +// program through. One case per rule; the comment names the rule, so relaxing +// one shows up as a deleted assertion rather than a quiet behavior shift. The +// script-parsing and tsconfig-graph cases moved here with their code when the +// two guards were unified; their `(rN)` tags are the #1799 review rounds that +// found them. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + classifyModulePath, + crossInstallPackages, + isDisablingFlag, + isTsc, + parseTsconfigReferences, + projectConfigFile, + refToProject, + typecheckProjects, +} from "./tsc-program.mjs"; + +test("isTsc: matches by basename incl. path-invoked (r18 regression)", () => { + for (const t of [ + "tsc", + "node_modules/.bin/tsc", + "./node_modules/.bin/tsc.cmd", + ]) + assert.ok(isTsc(t), t); + for (const t of ["vitest", "prettier", "tscx", "atsc"]) + assert.ok(!isTsc(t), t); +}); + +test("isDisablingFlag: case-insensitive (r18)", () => { + for (const t of [ + "--noCheck", + "--nocheck", + "--listFilesOnly", + "--LISTFILESONLY", + ]) + assert.ok(isDisablingFlag(t), t); + for (const t of ["--noEmit", "-p", "--project", "noCheck"]) + assert.ok(!isDisablingFlag(t), t); +}); + +test("typecheckProjects: harvests -p / --project / -b, implicit tsconfig.json (r13)", () => { + const { projects, neutered } = typecheckProjects({ + typecheck: + "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", + }); + assert.deepEqual(projects, ["tsconfig.json", "tsconfig.test.json"]); + assert.equal(neutered.length, 0); + + // A bare `tsc` (no project flag) resolves the implicit ./tsconfig.json. + assert.deepEqual(typecheckProjects({ typecheck: "tsc --noEmit" }).projects, [ + "tsconfig.json", + ]); + + // Path-invoked binary still counts (r18). + assert.deepEqual( + typecheckProjects({ typecheck: "node_modules/.bin/tsc -p tsconfig.json" }) + .projects, + ["tsconfig.json"], + ); + + // A quoted project path (r17). + assert.deepEqual( + typecheckProjects({ typecheck: `tsc -p "tsconfig.test.json"` }).projects, + ["tsconfig.test.json"], + ); + + // `--project` long form, and `-b`/`--build` project paths (r13). + const proj = (cmd) => typecheckProjects({ typecheck: cmd }).projects; + assert.deepEqual(proj("tsc --noEmit --project tsconfig.json"), [ + "tsconfig.json", + ]); + assert.deepEqual(proj("tsc -b tsconfig.json"), ["tsconfig.json"]); + assert.deepEqual(proj("tsc --build tsconfig.json"), ["tsconfig.json"]); + assert.deepEqual(proj("tsc -b"), ["tsconfig.json"]); // implicit fallback +}); + +test("typecheckProjects: neutered by --noCheck / --listFilesOnly (r10)", () => { + const { projects, neutered } = typecheckProjects({ + typecheck: + "tsc --noEmit -p tsconfig.json --noCheck && tsc --noEmit -p tsconfig.test.json", + }); + assert.deepEqual(projects, ["tsconfig.test.json"]); + assert.deepEqual(neutered, [{ project: "tsconfig.json", flag: "--noCheck" }]); +}); + +test("typecheckProjects: delegating typecheck, ignores non-tsc segments (r15)", () => { + const { projects } = typecheckProjects({ + typecheck: "npm run typecheck:src && npm run typecheck:test", + "typecheck:src": "tsc --noEmit -p tsconfig.json", + "typecheck:test": "tsc --noEmit --project tsconfig.test.json", + }); + assert.deepEqual(projects.sort(), ["tsconfig.json", "tsconfig.test.json"]); +}); + +test("parseTsconfigReferences: JSONC tolerance (r17-nit2 block comments)", () => { + const refs = (raw) => parseTsconfigReferences(raw); + assert.deepEqual(refs('{ "references": [{ "path": "./a" }] }'), ["./a"]); + assert.deepEqual( + refs('/* solution */\n{ "references": [{ "path": "./a" }] }'), + ["./a"], + ); + assert.deepEqual(refs('{ "references": [{ "path": "./a" }] } // trailing'), [ + "./a", + ]); + assert.deepEqual(refs('{ "references": [{ "path": "./a" },] }'), ["./a"]); // trailing comma + assert.deepEqual(refs('{ "files": [] }'), []); // no references + assert.deepEqual(refs("{ not json"), []); // malformed + assert.deepEqual(refs('{ "references": [{ "prepend": true }] }'), []); // no path +}); + +test("projectConfigFile: directory-form entry means /tsconfig.json (r26)", () => { + assert.equal( + projectConfigFile("clients/cli", "tsconfig.test.json"), + "clients/cli/tsconfig.test.json", + ); + assert.equal( + projectConfigFile("clients/cli", "packages/a"), + "clients/cli/packages/a/tsconfig.json", + ); + assert.equal( + projectConfigFile("clients/cli", "."), + "clients/cli/tsconfig.json", + ); +}); + +test("refToProject: refs resolve against the REFERRING config's dir (r26)", () => { + // A ref is relative to the tsconfig that declares it, not to clientDir. + assert.equal( + refToProject( + "clients/web", + "clients/web/tsconfig.json", + "./tsconfig.app.json", + ), + "tsconfig.app.json", + ); + assert.equal( + refToProject( + "clients/web", + "clients/web/sub/tsconfig.json", + "../other.json", + ), + "other.json", + ); + assert.equal( + refToProject("clients/web", "clients/web/sub/tsconfig.json", "./deep"), + "sub/deep", + ); +}); + +test("classifyModulePath: install root is the OUTERMOST node_modules, package the innermost (#1965)", () => { + assert.deepEqual(classifyModulePath("node_modules/zod/index.d.ts"), { + installRoot: ".", + name: "zod", + }); + assert.deepEqual( + classifyModulePath("clients/web/node_modules/zod/index.d.ts"), + { installRoot: "clients/web", name: "zod" }, + ); + // A scoped package keeps both segments. + assert.deepEqual( + classifyModulePath("node_modules/@modelcontextprotocol/sdk/dist/x.d.ts"), + { installRoot: ".", name: "@modelcontextprotocol/sdk" }, + ); + // A NESTED copy folds onto its outermost install: npm resolving a transitive + // conflict inside one install is routine, not a cross-install skew, and the + // lockfile comparison ignores it for the same reason. + assert.deepEqual( + classifyModulePath("node_modules/a/node_modules/zod/index.d.ts"), + { installRoot: ".", name: "zod" }, + ); + assert.deepEqual( + classifyModulePath( + "clients/cli/node_modules/a/node_modules/@scope/b/index.d.ts", + ), + { installRoot: "clients/cli", name: "@scope/b" }, + ); +}); + +test("classifyModulePath: paths that name no package", () => { + for (const rel of [ + "core/mcp/client.ts", // first-party source + "clients/web/src/App.tsx", + "node_modules/.bin/tsc", // npm bookkeeping, not a package + "node_modules/.package-lock.json", + "node_modules/@scope", // a scope directory names no package + "node_modules", // the directory itself + ]) + assert.equal(classifyModulePath(rel), null, rel); +}); + +test("classifyModulePath: a path segment merely CONTAINING node_modules is not one", () => { + // Segment-wise matching, not substring: a first-party dir whose name embeds + // the word would otherwise be read as an install root. + assert.equal(classifyModulePath("core/node_modules_fixtures/a.ts"), null); +}); + +test("crossInstallPackages: two installs in ONE program is the whole test (#1965)", () => { + const found = crossInstallPackages([ + { + label: "clients/web/tsconfig.test.json", + files: [ + "node_modules/zod/index.d.ts", + "clients/web/node_modules/zod/index.d.ts", + "clients/web/node_modules/react/index.d.ts", + ], + }, + ]); + assert.deepEqual([...found.keys()], ["zod"]); + assert.deepEqual([...found.get("zod").installRoots].sort(), [ + ".", + "clients/web", + ]); + assert.deepEqual( + [...found.get("zod").programs], + ["clients/web/tsconfig.test.json"], + ); +}); + +test("crossInstallPackages: two installs across SEPARATE programs is not a candidate", () => { + // Two copies the type checker never has to relate — each program sees one. + const found = crossInstallPackages([ + { label: "a", files: ["node_modules/zod/index.d.ts"] }, + { label: "b", files: ["clients/cli/node_modules/zod/index.d.ts"] }, + ]); + assert.equal(found.size, 0); +}); + +test("crossInstallPackages: a nested duplicate inside one install is not a candidate", () => { + const found = crossInstallPackages([ + { + label: "a", + files: [ + "node_modules/zod/index.d.ts", + "node_modules/other/node_modules/zod/index.d.ts", + ], + }, + ]); + assert.equal(found.size, 0); +}); + +test("crossInstallPackages: every program that saw both copies is recorded", () => { + const both = [ + "node_modules/zod/index.d.ts", + "clients/web/node_modules/zod/index.d.ts", + ]; + const found = crossInstallPackages([ + { label: "web/app", files: both }, + { label: "web/test", files: both }, + { label: "cli/src", files: ["node_modules/zod/index.d.ts"] }, + ]); + assert.deepEqual([...found.get("zod").programs].sort(), [ + "web/app", + "web/test", + ]); +}); diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs index c00379d7..eb410167 100644 --- a/scripts/verify-dep-lockstep.main.test.mjs +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -1,14 +1,21 @@ // End-to-end tests for the dep-lockstep guard's executable path (Copilot, // #1962). The sibling tests cover the pure helpers; nothing exercised `main()`, -// so a regression in source enumeration, install discovery, lockfile loading, +// so a regression in candidate derivation, install discovery, lockfile loading, // the sibling-guard vouch, or the nonzero exit on real skew would have left the // whole suite green. // // Each case builds a throwaway repo — the guard derives its root from its own // file location, so the script is copied into the fixture rather than pointed -// at one — `git add`s it (the enumeration is `git ls-files`, which reads the -// index), and runs the guard as a subprocess to assert the exit status and -// message. Run via `npm run test:scripts`. +// at one — `git add`s it (install discovery is by manifest, but the sibling +// `verify:format-coverage` vouch and git-based tooling expect a repo), and runs +// the guard as a subprocess to assert the exit status and message. +// +// Since #1965 the fixture must contain a REAL tsc program: the candidate set is +// derived from what `tsc --listFilesOnly` resolves, so each fixture ships two +// installs holding the same tiny stub packages, one client tsconfig whose +// program spans both (its own `src` resolves from the client install, the shared +// `core/` it includes resolves from the root — the actual v2 shape), and a +// symlinked real `typescript` to run it. Run via `npm run test:scripts`. import { test } from "node:test"; import assert from "node:assert/strict"; @@ -30,7 +37,7 @@ import { fileURLToPath } from "node:url"; const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.join(scriptsDir, ".."); -/** The real typescript install, symlinked into each fixture so the guard resolves it. */ +/** The real typescript install, symlinked into each fixture so `npx tsc` runs. */ const typescriptDir = path.dirname( createRequire(path.join(repoRoot, "clients", "web", "package.json")).resolve( "typescript/package.json", @@ -52,11 +59,24 @@ const lock = (deps) => ({ }); /** - * Build a fixture repo: shared sources importing `zod` and `express`, a root - * install and one client install, and the guard itself. `rootDeps`/`webDeps` - * decide whether the two installs agree. + * Build a fixture repo whose one client program spans two installs. + * + * `outer` is named by first-party code in both trees; `inner` is named ONLY by + * `outer`'s own `.d.ts`, so it reaches the program transitively — the + * `@modelcontextprotocol/sdk` shape #1965 is about. Both stubs are installed + * under the root AND under `clients/web`, so each program holds two copies of + * each and both are candidates. `solo` is installed at the root only and + * imported only by the shared tree, so it can never be a candidate however its + * lockfile entries read. */ -function makeFixture({ rootDeps, webDeps, scripts, rawWebLock }) { +function makeFixture({ + rootDeps, + webDeps, + scripts, + webScripts, + rawWebLock, + webTsconfig, +} = {}) { // realpath matters: on macOS `tmpdir()` is `/var/...`, a symlink to // `/private/var/...`. The guard only runs `main()` when `import.meta.url` // (always the resolved path) matches `process.argv[1]`, so launching it via @@ -81,34 +101,91 @@ function makeFixture({ rootDeps, webDeps, scripts, rawWebLock }) { "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", }, }); - write("core/client.ts", 'import { z } from "zod";\nexport const s = z;\n'); + + // The shared tree, compiled into the client's program but resolving its own + // imports from the ROOT install — the reason a package can appear twice. write( - "test-servers/src/server.ts", - 'import express from "express";\nexport const app = express;\n', + "core/client.ts", + 'import type { Outer } from "outer";\nimport type { Solo } from "solo";\nexport type FromCore = Outer | Solo;\n', ); + // The client's own source, resolving from the CLIENT install. write( - "vitest.shared.mts", - 'import path from "node:path";\nexport default path;\n', + "clients/web/src/main.ts", + 'import type { Outer } from "outer";\nexport type FromWeb = Outer;\n', ); - write("package-lock.json", lock(rootDeps)); - write("clients/web/package.json", { name: "web" }); - write("clients/web/package-lock.json", rawWebLock ?? lock(webDeps)); + write("clients/web/package.json", { + name: "web", + scripts: webScripts ?? { typecheck: "tsc --noEmit -p tsconfig.json" }, + }); + write( + "clients/web/tsconfig.json", + webTsconfig ?? { + compilerOptions: { + noEmit: true, + module: "esnext", + target: "esnext", + moduleResolution: "bundler", + types: [], + }, + include: ["src/**/*.ts", "../../core/**/*.ts"], + }, + ); + + // Stub installs. `outer` pulls `inner` in through its own declarations. The + // installed version must match the lockfile's: TypeScript keys its + // package-identity dedup on name@version, so a stub that lied about its + // version would make the program under test differ from the one the lockfile + // describes. + const root = rootDeps ?? ALIGNED; + const web = webDeps ?? ALIGNED; + const stub = (installRoot, versions, name, body) => { + if (!versions[name]) return; + write(`${installRoot}node_modules/${name}/package.json`, { + name, + version: versions[name], + types: "index.d.ts", + }); + write(`${installRoot}node_modules/${name}/index.d.ts`, body); + }; + for (const [installRoot, versions] of [ + ["", root], + ["clients/web/", web], + ]) { + stub( + installRoot, + versions, + "outer", + 'import type { Inner } from "inner";\nexport type Outer = Inner;\n', + ); + stub(installRoot, versions, "inner", "export type Inner = number;\n"); + } + stub("", root, "solo", "export type Solo = string;\n"); + + write("package-lock.json", lock(root)); + write("clients/web/package-lock.json", rawWebLock ?? lock(web)); // The guard resolves its repo root from its own location, so it has to live - // inside the fixture; `lib/npm-scripts.mjs` comes along as its import. + // inside the fixture; its `lib/` imports come along. mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true }); for (const rel of [ "verify-dep-lockstep.mjs", path.join("lib", "npm-scripts.mjs"), + path.join("lib", "tsc-program.mjs"), ]) cpSync(path.join(scriptsDir, rel), path.join(dir, "scripts", rel)); - mkdirSync(path.join(dir, "node_modules"), { recursive: true }); + // A real typescript, plus the `.bin` entry `npx --no-install tsc` resolves by + // walking up from the client dir. + mkdirSync(path.join(dir, "node_modules", ".bin"), { recursive: true }); symlinkSync( typescriptDir, path.join(dir, "node_modules", "typescript"), "dir", ); + symlinkSync( + path.join(typescriptDir, "bin", "tsc"), + path.join(dir, "node_modules", ".bin", "tsc"), + ); execFileSync("git", ["init", "-q"], { cwd: dir }); execFileSync("git", ["add", "-A"], { cwd: dir }); @@ -134,69 +211,110 @@ function withFixture(options, fn) { } } -const ALIGNED = { zod: "4.4.3", express: "5.2.1" }; +const ALIGNED = { outer: "1.2.3", inner: "4.5.6", solo: "7.8.9" }; test("main: exits 0 when every install agrees", () => { - withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + withFixture({}, (dir) => { const { status, out } = runGuard(dir); assert.equal(status, 0, out); assert.match(out, /verify:dep-lockstep — OK/); - // Both shared trees and the named shared file contributed their imports. - assert.match(out, /2 install-crossing dependencies/); + // `outer` alone: both installs hold it and both files land in the program. + // `solo` is installed at the root only, so it is not install-crossing — and + // `inner`, at the same version in both installs, is collapsed by + // TypeScript's package-identity redirect (see the note on `crossInstallPackages` + // in lib/tsc-program.mjs): only one copy is ever loaded, so there is nothing + // for the checker to relate. The next test is the version that matters. + assert.match(out, /1 install-crossing dependencies/); + assert.match(out, /1 tsc programs/); }); }); test("main: exits 1 and names the skewed package and every holder", () => { withFixture( - { rootDeps: { ...ALIGNED, zod: "4.3.6" }, webDeps: ALIGNED }, + { rootDeps: { ...ALIGNED, outer: "1.0.0" }, webDeps: ALIGNED }, (dir) => { const { status, out } = runGuard(dir); assert.equal(status, 1, out); - assert.match(out, /\bzod\b/); - assert.match(out, /4\.3\.6\s+\(\.\)/); - assert.match(out, /4\.4\.3\s+\(clients\/web\)/); - // `express` agrees, so it must not be reported. - assert.doesNotMatch(out, /^\s+express$/m); + assert.match(out, /\bouter\b/); + assert.match(out, /1\.0\.0\s+\(\.\)/); + assert.match(out, /1\.2\.3\s+\(clients\/web\)/); + // The program that saw both copies is named, so the claim is checkable. + assert.match(out, /seen in clients\/web\/tsconfig\.json/); + // `inner` agrees, so it must not be reported. + assert.doesNotMatch(out, /^\s+inner$/m); }, ); }); -test("main: a package imported only by test-servers/src is still checked", () => { - // Guards the enumeration of the *second* shared tree: if only `core/` were - // scanned, this skew would pass. +test("main: a package reached only through another package's .d.ts is caught (#1965)", () => { + // `inner` is never written in first-party code — it enters the program solely + // through `outer`'s declarations. The derivation this replaced read the + // shared sources' own imports and could not see it, which is how + // `@modelcontextprotocol/sdk` sat skewed across two installs while the guard + // stayed green. Its parent skews too, which is what puts both copies of the + // parent in the program to resolve from — the real #1965 shape, where + // `ext-apps` was split 1.7.4/1.7.5 alongside the SDK. withFixture( - { rootDeps: { ...ALIGNED, express: "5.0.0" }, webDeps: ALIGNED }, + { + rootDeps: { ...ALIGNED, outer: "1.0.0", inner: "4.0.0" }, + webDeps: ALIGNED, + }, (dir) => { const { status, out } = runGuard(dir); assert.equal(status, 1, out); - assert.match(out, /\bexpress\b/); + assert.match(out, /^\s+inner$/m); + assert.match(out, /4\.0\.0\s+\(\.\)/); + assert.match(out, /4\.5\.6\s+\(clients\/web\)/); }, ); }); -test("main: exits 1 when a configured shared source matches no file", () => { - withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { - // Drop `core/` from the index — the other sources keep the file count - // nonzero, which is exactly what the old aggregate check missed. - execFileSync("git", ["rm", "-r", "-q", "--cached", "core"], { cwd: dir }); +test("main: a package that reaches the program from ONE install is not a candidate", () => { + // `solo` is installed at the root only, so no program ever holds two copies of + // it — its lockfile entries can disagree without TypeScript ever relating two + // declarations. Failing here would be the old derivation's false positive + // (which is what made an allowlist of inert names necessary). + withFixture( + { rootDeps: { ...ALIGNED, solo: "7.0.0" }, webDeps: ALIGNED }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 0, out); + assert.doesNotMatch(out, /\bsolo\b/); + }, + ); +}); + +test("main: exits 1 when a client names no tsconfig project", () => { + // No `typecheck` script and no `tsconfig.json` references: none of that + // client's programs is measured, so a skew reaching only them would pass. + withFixture({ webScripts: { build: "vite build" } }, (dir) => { + rmSync(path.join(dir, "clients", "web", "tsconfig.json")); const { status, out } = runGuard(dir); assert.equal(status, 1, out); - assert.match(out, /matched no tracked file/); - assert.match(out, /^\s+core$/m); + assert.match(out, /names no tsconfig project/); + }); +}); + +test("main: exits 1 when a program cannot be listed, rather than measuring nothing", () => { + // A broken tsconfig makes `tsc --listFilesOnly` resolve nothing. Reading that + // as "no candidates here" is the gate failing open. + withFixture({ webTsconfig: "{ this is not json" }, (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /exited non-zero/); }); }); test("main: exits 1 on a lockfile it cannot read, rather than failing open (Copilot, #1962)", () => { // A v1 lockfile has `dependencies` and no `packages` table. Treating it as an - // install that simply holds nothing would leave the *root's* zod unopposed + // install that simply holds nothing would leave the *root's* copy unopposed // and the skew below reported as aligned — the gate failing open. withFixture( { - rootDeps: { ...ALIGNED, zod: "4.3.6" }, - webDeps: ALIGNED, + rootDeps: { ...ALIGNED, outer: "1.0.0" }, rawWebLock: { lockfileVersion: 1, - dependencies: { zod: { version: "4.4.3" } }, + dependencies: { outer: { version: "1.2.3" } }, }, }, (dir) => { @@ -213,11 +331,9 @@ test("main: exits 1 on a lockfile with a `packages` table but no root entry", () // `""` root npm always writes is not a lockfile this guard can trust. withFixture( { - rootDeps: ALIGNED, - webDeps: ALIGNED, rawWebLock: { lockfileVersion: 3, - packages: { "node_modules/zod": { version: "4.4.3" } }, + packages: { "node_modules/outer": { version: "1.2.3" } }, }, }, (dir) => { @@ -232,22 +348,19 @@ test("main: exits 1 on an install whose lockfile is missing (Copilot, #1962)", ( // Enrolment is by `package.json`, so a missing lockfile is loud rather than a // silently absent row. The fixture IS skewed, so dropping the install instead // would leave the remaining holder unopposed and report aligned. - withFixture( - { rootDeps: { ...ALIGNED, zod: "4.3.6" }, webDeps: ALIGNED }, - (dir) => { - rmSync(path.join(dir, "clients", "web", "package-lock.json")); - const { status, out } = runGuard(dir); - assert.equal(status, 1, out); - assert.match(out, /no lockfile/); - assert.match(out, /clients\/web\/package-lock\.json/); - }, - ); + withFixture({ rootDeps: { ...ALIGNED, outer: "1.0.0" } }, (dir) => { + rmSync(path.join(dir, "clients", "web", "package-lock.json")); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /no lockfile/); + assert.match(out, /clients\/web\/package-lock\.json/); + }); }); test("main: exits 1 when the ROOT lockfile is missing", () => { // The worst variant: the root is the install every shared source resolves // from, so omitting it could let the guard pass on client locks alone. - withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + withFixture({}, (dir) => { rmSync(path.join(dir, "package-lock.json")); const { status, out } = runGuard(dir); assert.equal(status, 1, out); @@ -257,8 +370,9 @@ test("main: exits 1 when the ROOT lockfile is missing", () => { }); test("main: a clients/ dir with no package.json is not an install", () => { - // Enrolling a stray directory would demand a lockfile it should never have. - withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + // Enrolling a stray directory would demand a lockfile it should never have, + // and a tsconfig project it has no way to name. + withFixture({}, (dir) => { mkdirSync(path.join(dir, "clients", "scratch"), { recursive: true }); writeFileSync( path.join(dir, "clients", "scratch", "notes.md"), @@ -269,11 +383,21 @@ test("main: a clients/ dir with no package.json is not an install", () => { }); }); +test("main: exits 1 when there is no client program to measure at all", () => { + // A moved `clients/` dir leaves the derivation with nothing to look at, and an + // empty candidate set from a broken enumeration reads exactly like a clean + // bill of health. + withFixture({}, (dir) => { + rmSync(path.join(dir, "clients"), { recursive: true, force: true }); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /found no client program to measure/); + }); +}); + test("main: exits 1 when the root validate no longer runs the sibling guard", () => { withFixture( { - rootDeps: ALIGNED, - webDeps: ALIGNED, // `verify:format-coverage` dropped from the chain: the vouch must fail // even though the dependency versions themselves are fine. scripts: { diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 27b5812c..3fe3a40e 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -20,210 +20,89 @@ // `@modelcontextprotocol/*` schema is built out of zod generics. Aligning the // two copies — changing nothing else — returned the build to its baseline cost. // -// The candidate set is DERIVED, not hand-listed: it is the packages imported by -// the shared first-party TypeScript — `core/`, `test-servers/src`, and the -// root-owned `vitest.shared.mts` — the surfaces compiled into more than one -// client's program. Skew is then denied by default, with a small -// allowlist of packages verified to tolerate it (below). A dependency that -// starts skewing therefore fails `validate` and forces a decision, rather than -// surfacing months later as an unexplained OOM. +// The candidate set is DERIVED from what actually enters each `tsc` program +// (#1965): every client tsconfig project is listed with `tsc --listFilesOnly` +// (the shared `lib/tsc-program.mjs` machinery `verify:typecheck-coverage` reads +// too), each resolved `node_modules` file is mapped to its owning install and +// package, and a package that reaches ONE program from TWO installs is a +// candidate. That is exactly the set that can put two structurally-distinct +// copies of a type in front of one type checker — no more, no less. // -// KNOWN BOUNDARY (#1965): the candidate set covers packages the shared sources -// name *directly*. A package whose declarations reach the program only through -// another package's `.d.ts` is invisible here — `@modelcontextprotocol/sdk` is -// the live example, skewed root 1.29.0 vs `clients/web` 1.30.0 and present in -// web's program from both installs, yet never written in first-party code -// (the shared sources import the split `@modelcontextprotocol/client|core|…`). -// Two derivations were measured for closing this. A lockfile dependency -// closure is unusable — 155 packages, 25 of them skewed, nearly all irrelevant -// tooling (`chai`, `qs`, `iconv-lite`) — and it misses the SDK anyway. Reading -// what actually lands in each program (`tsc --listFilesOnly`, keeping packages -// present under two install roots) is both correct and small: 15 for -// `clients/web`, ~10 once nested duplicates are dropped. That is the right -// derivation and is tracked separately, since it changes what the guard -// measures and surfaces skews needing their own decisions. +// It replaced a derivation that read the packages the shared sources named +// *directly*, which could not see a package whose declarations arrive only +// through another package's `.d.ts`. `@modelcontextprotocol/sdk` was the live +// example: it is never written in first-party code (the shared sources import +// the split `@modelcontextprotocol/client|core|…`), yet 16 of its `.d.ts` files +// land in `clients/web`'s test program, so a second copy under +// `clients/web/node_modules` would have skewed unseen. The other derivation +// measured for #1965 — expanding the direct imports over the lockfiles' +// `dependencies` — was unusable: 155 packages, 25 of them skewed, nearly all +// irrelevant transitive tooling (`chai`, `qs`, `iconv-lite`), and it missed the +// SDK anyway. +// +// Skew among the candidates is then denied by default, with an allowlist of +// packages verified to tolerate it (below). A dependency that starts skewing +// fails `validate` and forces a decision, rather than surfacing months later as +// an unexplained OOM. import { readFileSync, existsSync, readdirSync } from "node:fs"; -import { execFileSync } from "node:child_process"; -import { builtinModules, createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { rootReachesScript } from "./lib/npm-scripts.mjs"; +import { + clientTsconfigReferences, + crossInstallPackages, + projectListingError, + projectPackageFiles, + resolveLeafProjects, + typecheckProjects, +} from "./lib/tsc-program.mjs"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", ); -// The first-party source trees that are compiled into more than one install's -// `tsc` program, and so define which dependencies can appear twice in one -// program. `core/` is consumed by every client via the `@inspector/core` alias; -// `test-servers/src` is pulled into the web and cli test projects. -const SHARED_SOURCE_DIRS = ["core", "test-servers/src"]; - -// Individual root-owned TypeScript files that are shared the same way but sit -// outside those trees. `vitest.shared.mts` is imported by every client's vitest -// config, and `verify:typecheck-coverage` already treats it as shared -// non-client source. It imports only Node built-ins today — which is precisely -// why omitting it would go unnoticed until a third-party import appeared there, -// resolved from the root, and skewed (Copilot, #1962). -const SHARED_SOURCE_FILES = ["vitest.shared.mts"]; - // Packages whose cross-install skew is verified benign, each with the reason. // This is an allowlist of *names*, not of version pairs, so an ordinary patch // float within one of these does not churn the file — while any package NOT // listed here failing the check is a genuine, unreviewed new skew. // // Being listed is NOT a blanket exemption: it tolerates skew only *within a -// major version*. Each rationale below establishes that a patch/minor -// difference is harmless, which is not evidence that a React 18-vs-19 or Hono -// 4-vs-5 split across installs would be — that is a different type surface, and -// it fails like anything else (Copilot, #1962). +// major version*. A rationale establishes that a patch/minor difference is +// harmless, which is not evidence that a React 18-vs-19 or Hono 4-vs-5 split +// across installs would be — that is a different type surface, and it fails like +// anything else (Copilot, #1962). // // The admission test is the one the zod incident established: does the // package's public type surface consist of deeply recursive generics that // first-party code relates across the boundary? If yes it must stay in // lockstep; if no, a patch-level difference costs nothing. -const TOLERATED_SKEW = new Map([ - [ - "react", - "Types are shallow interfaces (`ReactNode`, `FC`), not recursive generics; the runtime copies never meet — each client bundles its own.", - ], - [ - "hono", - "Only used behind first-party wrappers in `core/mcp/remote/node`; its generic router types are not related across the boundary.", - ], - [ - "jose", - "Consumed as flat function calls in `core/auth`; no generic type flows between installs.", - ], - [ - "@modelcontextprotocol/ext-apps", - "Plain interface/constant surface for the MCP Apps UI protocol; no generic instantiation to blow up.", - ], -]); - -/** Package names that are Node built-ins (with or without the `node:` prefix). */ -const BUILTINS = new Set([ - ...builtinModules, - ...builtinModules.map((m) => `node:${m}`), -]); - -// A bare package specifier: optional `@scope/`, then a name, then any subpath. -// Anchored so prose that happens to sit after the word `from` in a comment -// ("from cwd omitted") cannot be mistaken for an import. -const PACKAGE_SPECIFIER = /^(?:(@[^/\s]+)\/)?([^@/\s][^/\s]*)(?:\/.*)?$/; - -/** - * The bare package name a module specifier resolves to — `@scope/name` or - * `name`, with any subpath dropped (`zod/v4` → `zod`). Returns null for - * relative paths, built-ins, URLs, and anything not shaped like a specifier. - */ -export function packageNameOf(specifier) { - if (typeof specifier !== "string" || specifier === "") return null; - if (specifier.startsWith(".") || specifier.startsWith("/")) return null; - if (BUILTINS.has(specifier)) return null; - const m = PACKAGE_SPECIFIER.exec(specifier); - if (!m) return null; - const name = m[1] ? `${m[1]}/${m[2]}` : m[2]; - // A protocol-ish specifier (`node:test`, `file:`, `data:`) is not a package. - if (name.includes(":")) return null; - return name; -} - -// Specifiers are extracted with TypeScript's own `preProcessFile` rather than -// by regex (Copilot, #1962 — raised across three review rounds, and correctly). -// A regex scan gets both directions wrong: it *misses* valid syntax (an -// `import x = require(…)` in a `.cts`, an import-attributes argument, a comment -// between tokens — each a silent miss, so the package never enters the -// candidate set and its skew passes), and it *invents* names from prose, since -// `// adapted from "react"` is indistinguishable from an import to a pattern -// that can't tell code from a comment. Every widening of the regex traded one -// of those failures for the other. // -// `preProcessFile` is TypeScript's lightweight pre-parse scanner — not a full -// parse and no type checking — and it is exactly built for this: it returns -// every module specifier, handling all import forms, trivia, strings, and -// regex literals correctly, and it never sees a comment as code. +// **Empty today**, and that is a consequence of the #1965 derivation rather than +// a relaxation. The four former entries were all admitted under the old +// direct-import derivation, which asked only "is this name written in shared +// source and held at two versions somewhere" — a question two installs can +// answer yes to without any program ever seeing both copies: // -// typescript is resolved from `clients/web`, which already carries it; the root -// has no TS dependency of its own. The `createRequire` base is load-bearing — -// a bare `import("typescript")` would resolve relative to `scripts/`, not the -// cwd (the same reason `smoke-web-browser.mjs` resolves playwright this way). -let tsCache; -function typescript() { - if (!tsCache) { - const require_ = createRequire( - path.join(repoRoot, "clients", "web", "package.json"), - ); - try { - tsCache = require_("typescript"); - } catch (cause) { - // Fail with the cause, not a bare MODULE_NOT_FOUND: the realistic way to - // get here is a root install run with INSPECTOR_SKIP_CLIENT_INSTALL=1, - // which leaves `clients/web/node_modules` empty. Silently skipping the - // check instead would be worse — an unrun guard guards nothing. - throw new Error( - "verify:dep-lockstep — could not resolve `typescript` from clients/web. " + - "Run `npm install` at the repo root (the postinstall cascade installs each client); " + - "if you set INSPECTOR_SKIP_CLIENT_INSTALL=1, this guard cannot run.", - { cause }, - ); - } - } - return tsCache; -} - -/** - * The package(s) a `/// ` directive can resolve to - * (Copilot, #1962). Such a directive pulls in declarations exactly like an - * import does, but TypeScript reports it separately from `importedFiles`, so - * reading only the latter would let a referenced package skew unseen. - * - * Both candidates are returned because the directive name is the *type* name, - * not the package: `node` resolves to `@types/node`, while a package shipping - * its own declarations resolves to itself. Returning both over-approximates, - * which is the safe direction — whichever isn't installed drops out. A scoped - * name mangles as `@scope/pkg` → `@types/scope__pkg`, TypeScript's convention. - */ -export function typeReferencePackageNames(directive) { - const name = packageNameOf(directive); - if (!name) return []; - const scoped = /^@([^/]+)\/(.+)$/.exec(name); - const typesName = scoped - ? `@types/${scoped[1]}__${scoped[2]}` - : `@types/${name}`; - return [name, typesName]; -} - -/** - * Every third-party package name whose declarations a blob of TypeScript source - * pulls in — via an import of any form, or a triple-slash type reference. - * Over-approximating is safe (a name absent from every lockfile contributes - * nothing downstream — `@inspector/core` is a build-time alias, not a package, - * and drops out that way); under-approximating is not, since a missed package - * never enters the candidate set and its skew would pass silently. - */ -export function importedPackageNames(source) { - const names = new Set(); - // (source, readImportFiles, detectJavaScriptImports) — the latter two make it - // report `require(…)` and dynamic imports as well as static ones. - const { importedFiles, typeReferenceDirectives } = - typescript().preProcessFile(source, true, true); - for (const { fileName } of importedFiles) { - const name = packageNameOf(fileName); - if (name) names.add(name); - } - for (const { fileName } of typeReferenceDirectives ?? []) - for (const name of typeReferencePackageNames(fileName)) names.add(name); - return names; -} +// • `jose` and `@modelcontextprotocol/ext-apps` are declared only at the root +// since #1970, so neither can skew at all now. +// • `react` ships no types of its own, so what lands in a program is +// `@types/react`, and it lands from a single install; the `react` package's +// own files never enter one. `hono` likewise resolves from one install per +// program (`clients/web` in web's node project). +// +// If any of them ever does reach one program from two installs, the guard fails +// and forces the decision then — with the actual version pair in hand, which is +// a better basis for a rationale than a pre-emptive entry. +const TOLERATED_SKEW = new Map(); /** * Top-level installed versions of every package in a parsed lockfile, keyed by * package name. Only `node_modules/` entries count — a *nested* * `node_modules/a/node_modules/b` is npm resolving a transitive conflict inside - * one install, which is routine and not what this guard is about. + * one install, which is routine and not what this guard is about (the same + * asymmetry `classifyModulePath` applies to a resolved path). */ export function topLevelLockVersions(lock) { const versions = new Map(); @@ -311,84 +190,124 @@ export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { }; } -// The TypeScript extensions the shared trees can hold. Deliberately the same -// four `verify:format-coverage` and `verify:typecheck-coverage` gate on: a -// `.mts`/`.cts` under `core/` or `test-servers/src` is typechecked like any -// other source, so its imports must reach the candidate set too. None exist -// under those trees today, which is exactly why omitting them would go -// unnoticed until a new shared dependency arrived through one and skewed. -const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"]; - /** - * Whether a repo-relative path is shared first-party TypeScript — under one of - * the shared trees, or one of the individually-named shared files. + * The tsconfig projects whose programs a client contributes, from its own + * `package.json` scripts and its root `tsconfig.json` `references` — the same + * two enrollment paths `verify:typecheck-coverage` uses, so the two guards + * measure the same programs. + * + * A `neutered` project (its `typecheck` command carries `--noCheck` / + * `--listFilesOnly`) counts here even though its sibling guard rejects it: + * whether that pass type-checks is that guard's question, while this one only + * asks what the program *resolves*, and a program still resolves its imports + * either way. Dropping them would shrink what this guard measures on the + * strength of a defect the other guard is already failing on. */ -export function isSharedSourceFile(file) { - if (SHARED_SOURCE_FILES.includes(file)) return true; - if (!SOURCE_EXTENSIONS.some((ext) => file.endsWith(ext))) return false; - // Anchored on a path boundary so a sibling whose name merely starts with a - // shared dir (`core-internal/`, `test-servers/src-legacy/`) isn't swept in. - return SHARED_SOURCE_DIRS.some((dir) => file.startsWith(`${dir}/`)); +export function clientProjects(scripts, references) { + if (typeof scripts?.typecheck === "string") { + const { projects, neutered } = typecheckProjects(scripts); + return [...projects, ...neutered.map((n) => n.project)]; + } + return references; } /** - * Which configured shared sources contributed no file to `files`. Each dir and - * each individually-named file must be represented; an aggregate count can't - * see one of them going missing, because the others keep the total nonzero. + * The `clients/*` directories that are real installs — one with a + * `package.json`. Enrolment is by manifest, NOT by the presence of a lockfile + * (Copilot, #1962): filtering on the lockfile made a missing one silently drop + * that install from the comparison. */ -export function sourcesWithNoFiles( - files, - dirs = SHARED_SOURCE_DIRS, - named = SHARED_SOURCE_FILES, -) { - const missingDirs = dirs.filter( - (dir) => !files.some((f) => f.startsWith(`${dir}/`)), - ); - const missingNamed = named.filter((name) => !files.includes(name)); - return [...missingDirs, ...missingNamed]; -} - -/** Tracked TypeScript files under the shared first-party source trees. */ -function sharedSourceFiles() { - const out = execFileSync( - "git", - [ - "ls-files", - "--", - ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`), - ...SHARED_SOURCE_FILES, - ], - { cwd: repoRoot, encoding: "utf8" }, - ); - return out.split("\n").filter(isSharedSourceFile); +function clientDirs() { + const dir = path.join(repoRoot, "clients"); + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => `clients/${e.name}`) + .filter((rel) => existsSync(path.join(repoRoot, rel, "package.json"))) + .sort(); } /** - * The installs to compare: the repo root plus every `clients/*` that carries a - * lockfile. Discovered from disk rather than listed, so a new client is covered - * without editing this guard (the same enrollment style as - * `verify:typecheck-coverage`). + * The installs to compare: the repo root plus every `clients/*` install. + * Discovered from disk rather than listed, so a new client is covered without + * editing this guard (the same enrollment style as `verify:typecheck-coverage`). + * The root is always enrolled: it is this repo, so its manifest is a given. */ function installDirs() { - const clientsDir = path.join(repoRoot, "clients"); - const clients = existsSync(clientsDir) - ? readdirSync(clientsDir, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => `clients/${e.name}`) - .sort() - : []; - // Enrolment is by `package.json` — an install we are meant to compare — - // NOT by the presence of a lockfile (Copilot, #1962). Filtering on the - // lockfile made a missing one silently drop that install from the - // comparison; for the root, the install every shared source resolves from, - // that meant the guard could report success from client locks alone. A - // missing lockfile is now a loud failure in `main`, not an absent row. The - // root is always enrolled: it is this repo, so its manifest is a given. - return ["."].concat( - clients.filter((dir) => - existsSync(path.join(repoRoot, dir, "package.json")), - ), - ); + return ["."].concat(clientDirs()); +} + +/** + * List every client program and reduce it to the packages that reach one program + * from two installs. Returns `{ found, programs, problems }` — `found` is the + * {@link crossInstallPackages} map, `programs` the labels listed (for the + * success line), `problems` any reason the measurement itself can't be trusted. + * + * A program that resolves NO installed file at all is a problem, not an empty + * result: every real program pulls in at least the toolchain's own `.d.ts` from + * some `node_modules`, so an empty listing means `tsc` never ran (a missing + * install) or the config is broken. Treating it as "no candidates here" is the + * gate failing open. + */ +function deriveCandidates() { + const problems = []; + const programs = []; + for (const clientDir of clientDirs()) { + let scripts; + try { + scripts = JSON.parse( + readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"), + ).scripts; + } catch (cause) { + throw new Error( + `verify:dep-lockstep — could not parse ${clientDir}/package.json.`, + { cause }, + ); + } + const projects = clientProjects( + scripts, + clientTsconfigReferences(clientDir), + ); + if (projects.length === 0) { + problems.push( + `${clientDir}: names no tsconfig project (no \`typecheck\` script, no \`tsconfig.json\` references) — none of its programs is measured.`, + ); + continue; + } + const leaves = new Set(); + for (const project of projects) + for (const leaf of resolveLeafProjects(clientDir, project)) + leaves.add(leaf); + for (const leaf of leaves) { + // A failed config is not a program: tsc still prints a file list, but it + // is whatever its fallback resolved rather than what the project declares, + // so measuring it would narrow the candidate set for a reason that has + // nothing to do with the dependency tree. + const error = projectListingError(clientDir, leaf); + if (error) { + problems.push( + `${clientDir}: \`tsc -p ${leaf} --listFilesOnly\` exited non-zero — ${error.split("\n")[0]}`, + ); + continue; + } + const files = projectPackageFiles(clientDir, leaf); + if (files.size === 0) { + problems.push( + `${clientDir}: \`tsc -p ${leaf} --listFilesOnly\` resolved no installed file — the program could not be listed.`, + ); + continue; + } + programs.push({ label: `${clientDir}/${leaf}`, files }); + } + } + // No program at all means the enumeration itself broke (a moved `clients/` + // dir), not that there is nothing to check — and an empty candidate set from a + // broken enumeration is indistinguishable from a clean bill of health. + if (programs.length === 0 && problems.length === 0) + problems.push( + "found no client program to measure — every `clients/*` install must contribute at least one tsconfig project.", + ); + return { found: crossInstallPackages(programs), programs, problems }; } /** @@ -411,29 +330,20 @@ export function main() { process.exit(1); } - const files = sharedSourceFiles(); - // Per-source, not an aggregate count (Copilot, #1962): `vitest.shared.mts` - // alone keeps the total nonzero, so a moved or renamed `core/` would leave - // the guard checking a near-empty candidate set and passing. Every configured - // source must contribute, or the enumeration is broken. - const empty = sourcesWithNoFiles(files); - if (empty.length > 0) { + const { found, programs, problems } = deriveCandidates(); + if (problems.length > 0) { console.error( - `verify:dep-lockstep — ${empty.length} configured shared source(s) matched no tracked file:\n`, + `verify:dep-lockstep — ${problems.length} program(s) could not be measured:\n`, ); - for (const source of empty) console.error(` ${source}`); + for (const problem of problems) console.error(` ${problem}`); console.error( - "\nThe guard would derive its candidates from an incomplete set and pass on skew it should catch." + - "\nA path was moved or renamed — fix SHARED_SOURCE_DIRS / SHARED_SOURCE_FILES in this file.", + "\nThe candidate set is derived from what each `tsc` program resolves, so an unmeasured program" + + "\nmeans real skew could pass unseen. Run `npm install` at the repo root (the postinstall cascade" + + "\ninstalls each client), and see `verify:typecheck-coverage` for the tsconfig-project enrollment rules.", ); process.exit(1); } - - const candidates = new Set(); - for (const file of files) { - const source = readFileSync(path.join(repoRoot, file), "utf8"); - for (const name of importedPackageNames(source)) candidates.add(name); - } + const candidates = new Set(found.keys()); const dirs = installDirs(); @@ -510,12 +420,15 @@ export function main() { ); for (const { dir, version } of holders) console.error(` ${version} (${dir})`); + // Which program saw both copies is the whole reason the package is a + // candidate — without it the reader has no way to check the claim. + for (const label of [...found.get(name).programs].sort()) + console.error(` seen in ${label}`); } - const shared = [...SHARED_SOURCE_DIRS, ...SHARED_SOURCE_FILES].join(", "); console.error( - "\nThese packages' types are compiled into a single `tsc` program from two installs" + - `\n(${shared} resolve from the root, a client's own sources from the client),` + - "\nso a version skew makes TypeScript relate two structurally-distinct copies of the same" + + "\nEach of these reaches a single `tsc` program from two installs (a client's own sources resolve" + + "\nfrom the client install, the shared `core/` + `test-servers/src` they pull in resolve from the" + + "\nroot), so a version skew makes TypeScript relate two structurally-distinct copies of the same" + "\ntype. For a recursive-generic surface like zod that is what exhausted the tsc heap in #1896.", ); console.error( @@ -534,7 +447,8 @@ export function main() { const note = ignored.length > 0 ? `, ${ignored.length} tolerated` : ""; console.log( - `verify:dep-lockstep — OK: ${candidates.size} install-crossing dependencies agree across ${installs.length} installs${note}.`, + `verify:dep-lockstep — OK: ${candidates.size} install-crossing dependencies agree across ${installs.length} installs${note} ` + + `(derived from ${programs.length} tsc programs).`, ); } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 55ca3b6f..25a643ab 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -3,258 +3,61 @@ // change that relaxes one is visible as a deleted assertion rather than a quiet // behavior shift. Run via `npm run test:scripts` (node:test; the root has no // vitest harness). +// +// The candidate derivation itself lives in `lib/tsc-program.mjs` (shared with +// `verify:typecheck-coverage` since #1965) and is covered by +// `lib/tsc-program.test.mjs`; what stays here is the lockfile comparison and the +// client-enrollment rule this guard owns. import { test } from "node:test"; import assert from "node:assert/strict"; import { + clientProjects, findSkew, hasReadableLockShape, - importedPackageNames, - isSharedSourceFile, majorOf, - packageNameOf, partitionSkew, - sourcesWithNoFiles, topLevelLockVersions, - typeReferencePackageNames, } from "./verify-dep-lockstep.mjs"; -test("isSharedSourceFile: all four TS extensions, not just .ts/.tsx", () => { - // `.mts`/`.cts` are gated by `verify:format-coverage` and - // `verify:typecheck-coverage` too. None exist under the shared trees today, - // so dropping them here would go unnoticed until a new shared dependency - // arrived through one — the skew this guard exists to catch (Copilot, #1962). - for (const ext of [".ts", ".tsx", ".mts", ".cts"]) { - assert.equal(isSharedSourceFile(`core/mcp/thing${ext}`), true, ext); - assert.equal( - isSharedSourceFile(`test-servers/src/thing${ext}`), - true, - `test-servers ${ext}`, - ); - } -}); - -test("isSharedSourceFile: non-TS files and other trees are excluded", () => { - const rejected = [ - "core/README.md", // not TypeScript - "core/mcp/data.json", - "clients/web/src/App.tsx", // a client's own sources resolve from the client - "scripts/verify-dep-lockstep.mjs", - "test-servers/configs/modern-http.json", - // Path-boundary anchoring: a sibling dir whose name merely starts with a - // shared dir's name must not be swept in. - "core-internal/thing.ts", - "test-servers/src-legacy/thing.ts", - ]; - for (const file of rejected) - assert.equal(isSharedSourceFile(file), false, file); -}); - -test("sourcesWithNoFiles: each configured source must contribute (Copilot, #1962)", () => { - const dirs = ["core", "test-servers/src"]; - const named = ["vitest.shared.mts"]; - const complete = [ - "core/mcp/a.ts", - "test-servers/src/b.ts", - "vitest.shared.mts", - ]; - assert.deepEqual(sourcesWithNoFiles(complete, dirs, named), []); - - // The failure an aggregate count can't see: `core/` moved, but the other two - // sources keep `files.length` nonzero, so the guard would derive candidates - // from an incomplete set and pass on skew it should catch. +test("clientProjects: a `typecheck` script's projects win over references", () => { + // Both enrollment paths exist (cli/tui/launcher declare `typecheck`, + // `clients/web` is a `tsc -b` solution) and this guard must measure the same + // programs as `verify:typecheck-coverage`, which prefers the script. assert.deepEqual( - sourcesWithNoFiles( - ["test-servers/src/b.ts", "vitest.shared.mts"], - dirs, - named, + clientProjects( + { + typecheck: "tsc --noEmit -p tsconfig.json && tsc -p tsconfig.test.json", + }, + ["./ignored.json"], ), - ["core"], + ["tsconfig.json", "tsconfig.test.json"], ); +}); + +test("clientProjects: a reference client is measured through its references", () => { assert.deepEqual( - sourcesWithNoFiles(["core/mcp/a.ts", "test-servers/src/b.ts"], dirs, named), - ["vitest.shared.mts"], + clientProjects({ build: "tsc -b && vite build" }, ["./a.json"]), + ["./a.json"], ); - assert.deepEqual(sourcesWithNoFiles([], dirs, named), [ - "core", - "test-servers/src", - "vitest.shared.mts", - ]); + // Neither path available — the caller reports it rather than measuring nothing. + assert.deepEqual(clientProjects({ build: "vite build" }, []), []); }); -test("sourcesWithNoFiles: a prefix sibling does not vouch for a dir", () => { - // `core-internal/` starts with `core` but is not it — the boundary check has - // to be on a path separator, or a renamed dir would look present. - assert.deepEqual(sourcesWithNoFiles(["core-internal/a.ts"], ["core"], []), [ - "core", - ]); -}); - -test("packageNameOf: bare names, scopes, and subpaths", () => { - const cases = [ - ["zod", "zod"], - ["zod/v4", "zod"], // subpath dropped — one package, one version - ["@modelcontextprotocol/client", "@modelcontextprotocol/client"], - ["@modelcontextprotocol/client/core", "@modelcontextprotocol/client"], - ["react-dom/client", "react-dom"], - ]; - for (const [input, expected] of cases) - assert.equal(packageNameOf(input), expected, input); -}); - -test("packageNameOf: non-packages are rejected", () => { - // Relative/absolute paths, built-ins with and without the `node:` prefix, - // protocol specifiers, and prose that follows the word `from` in a comment. - const rejected = [ - "./foo", - "../core/mcp", - "/abs/path", - "fs", - "path", - "node:crypto", - "node:test", - "file:", - "data:text/plain,x", - "cwd omitted", - "", - ]; - for (const input of rejected) - assert.equal(packageNameOf(input), null, JSON.stringify(input)); - assert.equal(packageNameOf(undefined), null); -}); - -test("importedPackageNames: CommonJS and awkward dynamic-import forms (Copilot, #1962)", () => { - // Under-approximating is the dangerous direction: a package the scan misses - // never enters the candidate set, so its skew passes the guard silently. - // `.cts` sources in the shared trees use `import x = require(…)` as ordinary - // syntax, and a dynamic import may carry import attributes or a static - // template literal — none of which the original three patterns matched. - const source = ` - import express = require("express"); - const yaml = require("yaml"); - const a = await import("undici", { with: { type: "json" } }); - const b = await import(\`jose\`); - `; - assert.deepEqual([...importedPackageNames(source)].sort(), [ - "express", - "jose", - "undici", - "yaml", - ]); -}); - -test("importedPackageNames: comment trivia between tokens (Copilot, #1962)", () => { - // TypeScript allows a comment anywhere whitespace is legal, so all of these - // are valid imports. Missing one is the dangerous direction: the package - // never enters the candidate set and its skew passes the guard silently. - const source = ` - import { a } from /* explanation */ "express"; - const b = await import(/* webpackIgnore: true */ "undici"); - const c = require(/* lazy */ "yaml"); - import /* side effect */ "pino"; - `; - assert.deepEqual([...importedPackageNames(source)].sort(), [ - "express", - "pino", - "undici", - "yaml", - ]); -}); - -test("importedPackageNames: line-comment trivia, not just block (Copilot, #1962)", () => { - // `//` runs to end-of-line and is legal in every position a block comment is, - // so a specifier can sit on the next line and these are still valid imports. - const source = [ - "import { a } from // reason", - ' "express";', - "const b = await import(// lazy", - ' "undici");', - "const c = require(// lazy", - ' "yaml");', - ].join("\n"); - assert.deepEqual([...importedPackageNames(source)].sort(), [ - "express", - "undici", - "yaml", - ]); -}); - -test("importedPackageNames: static, side-effect, and dynamic forms; builtins and relatives dropped", () => { - const source = ` - import { z } from "zod/v4"; - export type { Foo } from '@modelcontextprotocol/core'; - import "./side-effect.css"; - import "pino"; - const mod = await import("chokidar"); - import fs from "node:fs"; - import { helper } from "../local/helper"; - `; - assert.deepEqual([...importedPackageNames(source)].sort(), [ - "@modelcontextprotocol/core", - "chokidar", - "pino", - "zod", - ]); -}); - -test("importedPackageNames: triple-slash type references count (Copilot, #1962)", () => { - // A `/// ` pulls in declarations exactly like an - // import, but TypeScript reports it in `typeReferenceDirectives`, not - // `importedFiles` — so reading only the latter let a referenced package skew - // unseen. `path` references name a file, not a package, and are ignored. - const source = [ - '/// ', - '/// ', - '/// ', - 'import { z } from "zod";', - ].join("\n"); - assert.deepEqual([...importedPackageNames(source)].sort(), [ - "@types/express", - "@types/node", - "express", - "node", - "zod", - ]); -}); - -test("typeReferencePackageNames: both the bare and the @types form (Copilot, #1962)", () => { - // The directive names a *type*, not a package: `node` resolves to - // `@types/node`, while a package shipping its own declarations resolves to - // itself. Returning both over-approximates, the safe direction — whichever - // isn't installed drops out downstream. - assert.deepEqual(typeReferencePackageNames("node"), ["node", "@types/node"]); - // Scoped names mangle with a double underscore, TypeScript's convention. - assert.deepEqual(typeReferencePackageNames("@scope/pkg"), [ - "@scope/pkg", - "@types/scope__pkg", - ]); - assert.deepEqual(typeReferencePackageNames("./relative"), []); - assert.deepEqual(typeReferencePackageNames(""), []); -}); - -test("importedPackageNames: prose in comments never becomes a package (Copilot, #1962)", () => { - // The regex scan this replaced could not tell code from a comment, so - // `// adapted from "react"` added `react` to the candidate set — and if that - // installed package were skewed, an unrelated comment would fail `validate`. - // These use REAL package names, which is the case the old prose test missed: - // it only passed because `cwd omitted` isn't a valid package name. - const source = ` - // adapted from "react" - /** Mirrors the behavior of "express", see require("yaml") below. */ - /** The excluded set derived from \\\`hono\\\`-style paths. */ - // const disabled = await import("undici"); - import { z } from "zod"; - `; - assert.deepEqual([...importedPackageNames(source)], ["zod"]); -}); - -test("importedPackageNames: a specifier inside a string literal is not an import", () => { - const source = ` - const msg = 'run require("chokidar") to load it'; - const re = /"jose"/; - import { z } from "zod"; - `; - assert.deepEqual([...importedPackageNames(source)], ["zod"]); +test("clientProjects: a NEUTERED project still contributes its program (#1965)", () => { + // `--noCheck` stops that pass type-checking, which is the sibling guard's + // complaint; the program still resolves its imports, and dropping it here + // would shrink what THIS guard measures on the strength of that other defect. + assert.deepEqual( + clientProjects( + { + typecheck: + "tsc -p tsconfig.json --noCheck && tsc -p tsconfig.test.json", + }, + [], + ).sort(), + ["tsconfig.json", "tsconfig.test.json"], + ); }); test("topLevelLockVersions: nested duplicates are ignored", () => { @@ -479,13 +282,3 @@ test("majorOf: prerelease and build metadata are irrelevant", () => { for (const bad of ["next", "", undefined, null, "v4.4.3"]) assert.equal(majorOf(bad), null, JSON.stringify(bad)); }); - -test("isSharedSourceFile: individually-named shared files are included (Copilot, #1962)", () => { - // `vitest.shared.mts` is root-owned, imported by every client's vitest - // config, and already treated as shared by `verify:typecheck-coverage`. It - // imports only Node built-ins today, which is why omitting it would go - // unnoticed until a third-party import appeared there and skewed. - assert.equal(isSharedSourceFile("vitest.shared.mts"), true); - // Still anchored: a same-named file nested elsewhere is not the shared one. - assert.equal(isSharedSourceFile("clients/web/vitest.shared.mts"), false); -}); diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 20d6ab2c..5293db6e 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -47,6 +47,18 @@ import { rootRunsClientValidate, tokenize, } from "./lib/npm-scripts.mjs"; +// The `tsc --listFilesOnly` machinery lives in `lib/` because +// `verify-dep-lockstep` measures the same programs for a different question +// (#1965) — one implementation, so the two guards can't disagree about what a +// program contains. +import { + clientTsconfigReferences, + isDisablingFlag, + isTsc, + projectSourceFiles, + resolveLeafProjects, + typecheckProjects, +} from "./lib/tsc-program.mjs"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -70,15 +82,6 @@ const EXEMPT = new Map(); export const isRequiredSource = (rel) => /\.(ts|tsx|mts|cts)$/.test(rel) && !/\.d\.(ts|mts|cts)$/.test(rel); -// Match `tsc` by token basename so a path-invoked binary (`node_modules/.bin/ -// tsc`, `./node_modules/.bin/tsc.cmd`) counts, not just the bare `tsc` token. -export const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); - -// A flag that makes a `tsc` pass list files without type-checking them (so it -// gates nothing). Case-insensitive — tsc's own option parsing is. Shared by the -// `typecheck`-script path and the reference (`tsc -b`) path. -export const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t); - /** * Whether a tracked test file is matched by one of the `test:scripts` command's * globs. Delegates to node's own `path.matchesGlob` (22.5.0+; the repo floor is @@ -256,51 +259,6 @@ export function testScriptProblems(scripts, testFiles) { return problems; } -/** - * The `references` paths declared in the tsconfig at repo-relative `tsconfigRel` - * (a `tsc -b` solution config), or `[]` if it has none / isn't readable. Paths - * are as written (relative to that tsconfig's own directory). - */ -export function parseTsconfigReferences(raw) { - try { - // Tolerate JSONC — block AND line comments + trailing commas (tsconfig - // allows all; block comments are in fact the style of every other tsconfig - // here). Block comments are stripped first so a `//` inside one doesn't - // survive; a `//` inside a string value (e.g. an `https://` URL) is a - // theoretical false strip this guard's tsconfigs never hit. - const cfg = JSON.parse( - raw - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\/\/.*$/gm, "") - .replace(/,(\s*[}\]])/g, "$1"), - ); - return Array.isArray(cfg.references) - ? cfg.references.map((r) => r?.path).filter((p) => typeof p === "string") - : []; - } catch { - return []; - } -} - -export function tsconfigReferences(tsconfigRel) { - try { - return parseTsconfigReferences( - readFileSync(path.join(repoRoot, tsconfigRel), "utf8"), - ); - } catch { - return []; // unreadable file (e.g. a directory / missing path) - } -} - -/** - * The `references` in a client's root `tsconfig.json`. Non-empty for a `tsc -b` - * client (like `clients/web`, which has no `typecheck` script) — this guard - * enrolls it through these instead of exempting the whole tree. - */ -export function clientTsconfigReferences(clientDir) { - return tsconfigReferences(path.posix.join(clientDir, "tsconfig.json")); -} - /** * How the client's `validate` runs `tsc -b` (the pass that typechecks a * reference client): `"ok"` (a real `tsc -b`), `"neutered"` (a `tsc -b` carrying @@ -388,64 +346,6 @@ function nodeClients() { return { clients, problems }; } -/** - * The tsconfig projects a client's `typecheck` names, harvested from **every** - * script reachable from `typecheck` (not just the one string) so a delegating - * `typecheck` (`npm run typecheck:src && …`) still counts — matching how - * `verify-format-coverage.mjs` harvests globs across reachable scripts. Splits - * each script on `&&`/`||`/`;` so a flag on one command doesn't leak onto - * another. Each `tsc` command's project comes from `-p`/`--project` (or a - * `-b`/`--build` path); a `tsc` command with **no** project flag resolves the - * implicit `./tsconfig.json` (tsc's own default), so that idiomatic form counts - * too. Returns `{ projects, neutered }`: `neutered` names any project whose own - * command carries `--noCheck`/`--nocheck` or `--listFilesOnly` (matched - * case-insensitively — tsc's option parsing is) — a pass that lists files - * without type-checking them, which would otherwise satisfy the guard while - * checking nothing. The config-file form (`noCheck` set in the tsconfig) is - * caught separately by {@link projectDisablesChecking}. - * - * A harvested `tsc -b` **solution config** (`"files": []` + `references`) lists - * nothing itself; {@link projectFiles} expands it to its references, so this - * form is measured whether it reaches here (a `typecheck: "tsc -b"`) or the - * dedicated reference path (`clients/web`, which declares no `typecheck`). - * - * Minor limitations, all unreachable with the plain `-p --noEmit` passes here: - * the implicit-`./tsconfig.json` fallback assumes **no file operands** (`tsc - * ` ignores the config and checks only that file, but would be credited - * the whole config's file list); the `--noCheck`/`--listFilesOnly` detection - * ignores a following boolean, so the contrived explicit `--noCheck false` - * (checking *on*) is still treated as disabling; and the `&&`/`||`/`;` split - * runs before tokenizing, so a quoted operator inside an arg would split - * mid-token (project paths carry none of those). - */ -export function typecheckProjects(scripts) { - const projects = []; - const neutered = []; - const isFlag = (t) => t.startsWith("-"); - const isProjectFlag = (t) => ["-p", "--project", "-b", "--build"].includes(t); - for (const name of reachableScripts(scripts, "typecheck")) { - const cmd = scripts?.[name]; - if (typeof cmd !== "string") continue; - for (const segment of cmd.split(/&&|\|\||;/)) { - const tokens = tokenize(segment); - if (!tokens.some(isTsc)) continue; // only tsc commands name projects - const disabling = tokens.find(isDisablingFlag); - // A project path follows `-p`/`--project`/`-b`/`--build`; a tsc command - // with none uses the implicit `./tsconfig.json` (tsc's own default). - const named = []; - for (let i = 0; i < tokens.length; i++) - if (isProjectFlag(tokens[i]) && tokens[i + 1] && !isFlag(tokens[i + 1])) - named.push(tokens[i + 1]); - if (named.length === 0) named.push("tsconfig.json"); - for (const project of named) { - if (disabling) neutered.push({ project, flag: disabling }); - else projects.push(project); - } - } - } - return { projects, neutered }; -} - /** * Whether the tsconfig `project` sets `noCheck` (which disables type-checking as * thoroughly as the CLI flag, but can't be seen in the `typecheck` script @@ -466,116 +366,11 @@ function projectDisablesChecking(clientDir, project) { } } -/** - * Repo-relative POSIX paths of the files ONE project (no reference expansion) - * typechecks. Absolute paths outside the repo root (lib.d.ts) and anything under - * `node_modules` are dropped; the aliased `core/` + `test-servers/` sources stay - * in the set but are harmless — the set is only ever queried with client-relative - * paths. Cached: `resolveLeafProjects` and `projectFiles` both list a project. - */ -const rawFilesCache = new Map(); -function rawProjectFiles(clientDir, project) { - const key = `${clientDir}|${project}`; - const cached = rawFilesCache.get(key); - if (cached) return cached; - const absClient = path.join(repoRoot, clientDir); - let stdout; - try { - stdout = execFileSync( - "npx", - ["--no-install", "tsc", "-p", project, "--listFilesOnly"], - { cwd: absClient, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, - ); - } catch (err) { - // `--listFilesOnly` doesn't type-check, but a config error (an unreadable or - // malformed tsconfig) still exits non-zero while printing the resolved file - // list; keep stdout so a broken config doesn't mask a coverage gap. Echo the - // diagnostic — since this guard runs before any client's own `typecheck`, - // it's the first place a bad `-p` config surfaces, and without the reason - // the resulting "file in no project" report is misleading. tsc prints config - // errors (`error TS…`) to stdout, so scan both streams for them. - stdout = typeof err.stdout === "string" ? err.stdout : ""; - const streams = - stdout + "\n" + (typeof err.stderr === "string" ? err.stderr : ""); - const diagnostic = streams - .split("\n") - .filter((l) => /error TS\d+/.test(l)) - .join("\n") - .trim(); - console.warn( - `verify:typecheck-coverage — \`tsc -p ${project}\` (in ${clientDir}) exited non-zero:\n${diagnostic || "(no diagnostic captured)"}\n`, - ); - } - const covered = new Set(); - for (const line of stdout.split("\n")) { - const abs = line.trim(); - if (!abs) continue; - const rel = path.relative(repoRoot, abs); - if (rel.startsWith("..") || rel.includes("node_modules")) continue; - covered.add(rel.split(path.sep).join("/")); - } - rawFilesCache.set(key, covered); - return covered; -} - -/** - * The repo-relative tsconfig FILE a `clientDir`-relative `project` entry names. - * A directory-form entry (`{ "path": "./packages/a" }`, or `tsc -p src`) means - * `/tsconfig.json` — tsc's own rule. - */ -export function projectConfigFile(clientDir, project) { - const projectRel = path.posix.join(clientDir, project); - return projectRel.endsWith(".json") - ? projectRel - : path.posix.join(projectRel, "tsconfig.json"); -} - -/** - * A `references` entry `ref` (written relative to `fromConfigFile`'s own - * directory) as a `clientDir`-relative project path, the form the rest of the - * graph walk uses. - */ -export function refToProject(clientDir, fromConfigFile, ref) { - return path.posix.relative( - clientDir, - path.posix.join(path.posix.dirname(fromConfigFile), ref), - ); -} - -/** - * The leaf tsconfig projects `project` resolves to (paths relative to - * `clientDir`): itself if it lists files (or has no `references`), else its - * `references` expanded recursively. A `tsc -b` **solution config** (`{"files": - * [], "references": […]}`) lists nothing under `--listFilesOnly`, so this is how - * it's reduced to the real projects — and doing it here (not just inside - * `projectFiles`) is what lets BOTH coverage and the non-inertness check follow - * the same graph, so a `noCheck` in a *referenced* project is caught no matter - * which enrollment path harvested the solution. - */ -export function resolveLeafProjects(clientDir, project, seen = new Set()) { - if (seen.has(project)) return []; - seen.add(project); - // Lists files → a real leaf. (An empty set is a solution config, or a config - // that errored — either way the reference expansion below is the right next - // step: a broken config yields no references too.) - if (rawProjectFiles(clientDir, project).size > 0) return [project]; - const configFile = projectConfigFile(clientDir, project); - const refs = tsconfigReferences(configFile); - if (refs.length === 0) return [project]; // no files, no refs — itself - return refs.flatMap((ref) => - resolveLeafProjects( - clientDir, - refToProject(clientDir, configFile, ref), - seen, - ), - ); -} - /** Repo-relative files a project covers, following a solution config's references. */ function projectFiles(clientDir, project) { const covered = new Set(); for (const leaf of resolveLeafProjects(clientDir, project)) - for (const f of rawProjectFiles(clientDir, leaf)) covered.add(f); + for (const f of projectSourceFiles(clientDir, leaf)) covered.add(f); return covered; } diff --git a/scripts/verify-typecheck-coverage.test.mjs b/scripts/verify-typecheck-coverage.test.mjs index 22151c25..d2cca80a 100644 --- a/scripts/verify-typecheck-coverage.test.mjs +++ b/scripts/verify-typecheck-coverage.test.mjs @@ -2,24 +2,22 @@ // module exposes these without running the guard (its execution is behind // `main()`, called only when the file is run directly). Each case pins a rule a // #1799 review round found. Run via `npm run test:scripts`. +// +// The `tsc`-script and tsconfig-graph parsers this guard used to own moved to +// `lib/tsc-program.mjs` when `verify:dep-lockstep` began measuring the same +// programs (#1965); their cases moved to `lib/tsc-program.test.mjs` with them. import { test } from "node:test"; import assert from "node:assert/strict"; import { matchesTestGlob, - isDisablingFlag, isRequiredSource, - isTsc, - parseTsconfigReferences, - projectConfigFile, - refToProject, integrityAdvice, isScriptsTestFile, testScriptGlobs, testScriptNarrowingFlags, testScriptProblems, tscBuildStatus, - typecheckProjects, } from "./verify-typecheck-coverage.mjs"; test("isRequiredSource: TS extensions, ambient .d.ts excluded (r7)", () => { @@ -29,83 +27,6 @@ test("isRequiredSource: TS extensions, ambient .d.ts excluded (r7)", () => { assert.ok(!isRequiredSource(f), f); }); -test("isTsc: matches by basename incl. path-invoked (r18 regression)", () => { - for (const t of [ - "tsc", - "node_modules/.bin/tsc", - "./node_modules/.bin/tsc.cmd", - ]) - assert.ok(isTsc(t), t); - for (const t of ["vitest", "prettier", "tscx", "atsc"]) - assert.ok(!isTsc(t), t); -}); - -test("isDisablingFlag: case-insensitive (r18)", () => { - for (const t of [ - "--noCheck", - "--nocheck", - "--listFilesOnly", - "--LISTFILESONLY", - ]) - assert.ok(isDisablingFlag(t), t); - for (const t of ["--noEmit", "-p", "--project", "noCheck"]) - assert.ok(!isDisablingFlag(t), t); -}); - -test("typecheckProjects: harvests -p / --project / -b, implicit tsconfig.json (r13)", () => { - const { projects, neutered } = typecheckProjects({ - typecheck: - "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", - }); - assert.deepEqual(projects, ["tsconfig.json", "tsconfig.test.json"]); - assert.equal(neutered.length, 0); - - // A bare `tsc` (no project flag) resolves the implicit ./tsconfig.json. - assert.deepEqual(typecheckProjects({ typecheck: "tsc --noEmit" }).projects, [ - "tsconfig.json", - ]); - - // Path-invoked binary still counts (r18). - assert.deepEqual( - typecheckProjects({ typecheck: "node_modules/.bin/tsc -p tsconfig.json" }) - .projects, - ["tsconfig.json"], - ); - - // A quoted project path (r17). - assert.deepEqual( - typecheckProjects({ typecheck: `tsc -p "tsconfig.test.json"` }).projects, - ["tsconfig.test.json"], - ); - - // `--project` long form, and `-b`/`--build` project paths (r13). - const proj = (cmd) => typecheckProjects({ typecheck: cmd }).projects; - assert.deepEqual(proj("tsc --noEmit --project tsconfig.json"), [ - "tsconfig.json", - ]); - assert.deepEqual(proj("tsc -b tsconfig.json"), ["tsconfig.json"]); - assert.deepEqual(proj("tsc --build tsconfig.json"), ["tsconfig.json"]); - assert.deepEqual(proj("tsc -b"), ["tsconfig.json"]); // implicit fallback -}); - -test("typecheckProjects: neutered by --noCheck / --listFilesOnly (r10)", () => { - const { projects, neutered } = typecheckProjects({ - typecheck: - "tsc --noEmit -p tsconfig.json --noCheck && tsc --noEmit -p tsconfig.test.json", - }); - assert.deepEqual(projects, ["tsconfig.test.json"]); - assert.deepEqual(neutered, [{ project: "tsconfig.json", flag: "--noCheck" }]); -}); - -test("typecheckProjects: delegating typecheck, ignores non-tsc segments (r15)", () => { - const { projects } = typecheckProjects({ - typecheck: "npm run typecheck:src && npm run typecheck:test", - "typecheck:src": "tsc --noEmit -p tsconfig.json", - "typecheck:test": "tsc --noEmit --project tsconfig.test.json", - }); - assert.deepEqual(projects.sort(), ["tsconfig.json", "tsconfig.test.json"]); -}); - test("tscBuildStatus: ok / neutered / none (r25)", () => { const status = (build) => tscBuildStatus({ validate: "npm run build", build }); @@ -116,22 +37,6 @@ test("tscBuildStatus: ok / neutered / none (r25)", () => { assert.equal(status("tsc --noEmit -p tsconfig.json"), "none"); // -b required }); -test("parseTsconfigReferences: JSONC tolerance (r17-nit2 block comments)", () => { - const refs = (raw) => parseTsconfigReferences(raw); - assert.deepEqual(refs('{ "references": [{ "path": "./a" }] }'), ["./a"]); - assert.deepEqual( - refs('/* solution */\n{ "references": [{ "path": "./a" }] }'), - ["./a"], - ); - assert.deepEqual(refs('{ "references": [{ "path": "./a" }] } // trailing'), [ - "./a", - ]); - assert.deepEqual(refs('{ "references": [{ "path": "./a" },] }'), ["./a"]); // trailing comma - assert.deepEqual(refs('{ "files": [] }'), []); // no references - assert.deepEqual(refs("{ not json"), []); // malformed - assert.deepEqual(refs('{ "references": [{ "prepend": true }] }'), []); // no path -}); - test("matchesTestGlob: the guard's contract, not node's glob engine", () => { // Only the two properties the guard actually relies on — the rest of node's // glob semantics are node's to test, which is the point of delegating to it. @@ -386,42 +291,3 @@ test("integrityAdvice: typecheck footer only for typecheck issues (r35 finding 2 /Restore the `typecheck` wiring/, ); }); - -test("projectConfigFile: directory-form entry means /tsconfig.json (r26)", () => { - assert.equal( - projectConfigFile("clients/cli", "tsconfig.test.json"), - "clients/cli/tsconfig.test.json", - ); - assert.equal( - projectConfigFile("clients/cli", "packages/a"), - "clients/cli/packages/a/tsconfig.json", - ); - assert.equal( - projectConfigFile("clients/cli", "."), - "clients/cli/tsconfig.json", - ); -}); - -test("refToProject: refs resolve against the REFERRING config's dir (r26)", () => { - // A ref is relative to the tsconfig that declares it, not to clientDir. - assert.equal( - refToProject( - "clients/web", - "clients/web/tsconfig.json", - "./tsconfig.app.json", - ), - "tsconfig.app.json", - ); - assert.equal( - refToProject( - "clients/web", - "clients/web/sub/tsconfig.json", - "../other.json", - ), - "other.json", - ); - assert.equal( - refToProject("clients/web", "clients/web/sub/tsconfig.json", "./deep"), - "sub/deep", - ); -}); From a9b9a917e30eaf5ce4275f711581cc9ced419e38 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 16 Aug 2026 12:35:38 -0400 Subject: [PATCH 56/58] chore(scripts): compare only the copies a program actually met MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the Copilot review of #1965, both real. 1. The candidates were flattened to package names before the version comparison, so a third install holding a different copy failed the guard even when no program loads that copy beside another — and the diagnostic named an install that never took part. `crossInstallPackages` now returns the co-occurrences themselves (name → program → install → entry paths) and `findSkew` compares per program, across only the installs that met there. 2. Folding a nested copy onto its outermost install lost the path that identifies which copy was loaded, and the version was then read from the install's top-level entry — absent or differently versioned, either way letting a real pair pass. `classifyModulePath` now also returns the `entryPath` npm keys the lockfile by, `topLevelLockVersions` becomes `lockVersionsByPath`, and each holder is priced from the entry the program resolved. A resolved copy with no lockfile entry is now a hard failure rather than a silently dropped holder. The failure output prints `in ` with each holder's install path beside its version, so a nested copy is distinguishable from a top-level one. Signed-off-by: cliffhall --- AGENTS.md | 2 +- README.md | 2 +- scripts/lib/tsc-program.mjs | 68 +++-- scripts/lib/tsc-program.test.mjs | 63 +++-- scripts/verify-dep-lockstep.main.test.mjs | 106 +++++++- scripts/verify-dep-lockstep.mjs | 132 ++++++--- scripts/verify-dep-lockstep.test.mjs | 310 +++++++++++++++------- 7 files changed, 495 insertions(+), 188 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eac73b7f..d07e84f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -694,7 +694,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib _resolution_ options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is _wired_ (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently _skips_ a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the _global_ union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from **what actually enters each `tsc` program** (#1965) — every client tsconfig project is listed with `tsc --listFilesOnly` through the shared `scripts/lib/tsc-program.mjs` helper (the same machinery `verify:typecheck-coverage` reads a program through, so the two guards can't disagree about what one contains), each resolved `node_modules` file is mapped to its owning install and package, and a package reaching **one** program from **two** installs is a candidate. That is precisely the set that can put two structurally-distinct copies of a type in front of one checker — and it needs no editing when a new dependency arrives. It replaced a derivation that read the packages the shared sources named *directly*, which could not see one whose declarations arrive only through another package's `.d.ts`: `@modelcontextprotocol/sdk` is never written in first-party code (the shared sources import the split `@modelcontextprotocol/client|core|…`) yet 16 of its `.d.ts` files land in `clients/web`'s test program, so a second copy under `clients/web/node_modules` skewed unseen. Two properties are worth knowing: a package present in two installs but reached from only one in a given program is correctly **not** a candidate, and TypeScript's package-identity redirect collapses two copies at the *same* name@version (so an aligned package's own transitive dependencies load once) — the redirect stops applying the moment they skew, which is exactly when the guard needs to see both. Versions themselves still come from the committed lockfiles' **top-level** `node_modules/` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and any candidate held at two versions fails, **deny-by-default**. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; it is **empty today** — the four names it used to carry (`react`, `hono`, `jose`, `@modelcontextprotocol/ext-apps`) were admitted under the old derivation and none is a candidate under this one, so each would be a rationale for a skew that cannot occur. **Being listed is not a blanket exemption** — it tolerates skew only *within a major version*, since a rationale about patch-level differences says nothing about a React 18-vs-19 split, where the type surface itself changes; a cross-major skew fails even for a listed package. **When bumping a dependency that the shared sources pull in, bump it in every install that declares it** — that's the root plus whichever clients list it, not all four unconditionally (launcher declares no zod, for instance, and a package absent from an install can't skew, so the guard ignores it there). Don't add a dependency to a client just to satisfy this. Its pure helpers are unit-tested via `test:scripts` (its own, plus `scripts/lib/tsc-program.test.mjs` for the shared derivation), and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. It runs the same `tsc --listFilesOnly` pass its sibling does, in its own process, costing ~14s: the listing is deliberately **not** cached to disk between the two, because a fingerprint that missed an input would make a guard measure a program that no longer exists and pass on a real miss. + - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from **what actually enters each `tsc` program** (#1965) — every client tsconfig project is listed with `tsc --listFilesOnly` through the shared `scripts/lib/tsc-program.mjs` helper (the same machinery `verify:typecheck-coverage` reads a program through, so the two guards can't disagree about what one contains), each resolved `node_modules` file is mapped to its owning install and package, and a package reaching **one** program from **two** installs is a candidate. That is precisely the set that can put two structurally-distinct copies of a type in front of one checker — and it needs no editing when a new dependency arrives. It replaced a derivation that read the packages the shared sources named *directly*, which could not see one whose declarations arrive only through another package's `.d.ts`: `@modelcontextprotocol/sdk` is never written in first-party code (the shared sources import the split `@modelcontextprotocol/client|core|…`) yet 16 of its `.d.ts` files land in `clients/web`'s test program, so a second copy under `clients/web/node_modules` skewed unseen. Two properties are worth knowing: a package present in two installs but reached from only one in a given program is correctly **not** a candidate, and TypeScript's package-identity redirect collapses two copies at the *same* name@version (so an aligned package's own transitive dependencies load once) — the redirect stops applying the moment they skew, which is exactly when the guard needs to see both. Versions still come from the committed lockfiles, but from the entry for the **exact install path the program resolved** (`node_modules/zod`, `node_modules/a/node_modules/zod`) rather than from the install's top-level entry: a *nested* duplicate inside one install is still not a candidate on its own — folding it onto its outermost install is what keeps the set small — but once a program has loaded one, pricing it from a top-level entry that may be absent or differently versioned would let a real pair pass (Copilot). Only the installs that actually **met in one program** are compared, so a third install's copy that no program loads beside another is not evidence of anything. Any co-occurrence whose copies disagree fails, **deny-by-default**; a resolved copy with no lockfile entry fails too, since the tree and the lockfile then disagree about what was loaded. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; it is **empty today** — the four names it used to carry (`react`, `hono`, `jose`, `@modelcontextprotocol/ext-apps`) were admitted under the old derivation and none is a candidate under this one, so each would be a rationale for a skew that cannot occur. **Being listed is not a blanket exemption** — it tolerates skew only *within a major version*, since a rationale about patch-level differences says nothing about a React 18-vs-19 split, where the type surface itself changes; a cross-major skew fails even for a listed package. **When bumping a dependency that the shared sources pull in, bump it in every install that declares it** — that's the root plus whichever clients list it, not all four unconditionally (launcher declares no zod, for instance, and a package absent from an install can't skew, so the guard ignores it there). Don't add a dependency to a client just to satisfy this. Its pure helpers are unit-tested via `test:scripts` (its own, plus `scripts/lib/tsc-program.test.mjs` for the shared derivation), and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. It runs the same `tsc --listFilesOnly` pass its sibling does, in its own process, costing ~14s: the listing is deliberately **not** cached to disk between the two, because a fingerprint that missed an input would make a guard measure a program that no longer exists and pass on a real miss. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. diff --git a/README.md b/README.md index 0bca3291..cb57dd35 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | | `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | -| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Compares those against the committed lockfiles' top-level entries and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist — empty today — and an allowlisted package is tolerated only *within a major version*. Runs in `validate`. +| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. Runs in `validate`. | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/scripts/lib/tsc-program.mjs b/scripts/lib/tsc-program.mjs index 189c13df..98a678e6 100644 --- a/scripts/lib/tsc-program.mjs +++ b/scripts/lib/tsc-program.mjs @@ -302,18 +302,26 @@ export function projectPackageFiles(clientDir, project) { } /** - * The install a repo-relative `node_modules` path belongs to, and the package it - * holds — `{ installRoot, name }`, or null for a path that names neither. + * Where a repo-relative `node_modules` path came from — `{ installRoot, name, + * entryPath }`, or null for a path that names no package. * - * The install root is the prefix before the **first** `node_modules` segment - * (`.` for the repo root, `clients/web` for a client install), while the package - * name comes from after the **last** one. That asymmetry is deliberate: a - * *nested* `node_modules/a/node_modules/b` is npm resolving a transitive - * conflict **inside one install**, which is routine and not a cross-install - * skew — the same reason the lockfile comparison reads only top-level entries. - * Folding it onto its outermost install is what keeps the candidate set the - * handful of genuinely install-crossing packages instead of every transitive - * duplicate in the tree. + * - `installRoot` is the prefix before the **first** `node_modules` segment + * (`.` for the repo root, `clients/web` for a client install). A *nested* + * `node_modules/a/node_modules/b` folds onto its outermost install, because it + * is npm resolving a transitive conflict **inside one install** — routine, and + * not the cross-install skew this feeds. Folding it is what keeps the + * candidate set the handful of genuinely install-crossing packages instead of + * every transitive duplicate in the tree. + * - `name` comes from after the **last** `node_modules`, so the nested copy is + * still attributed to the package it actually is. + * - `entryPath` is the package directory relative to that install — exactly the + * key npm writes in that install's lockfile (`node_modules/zod`, + * `node_modules/a/node_modules/zod`). Folding tells you *which install* loaded + * it; this tells you *which copy*, so the version can be read from the entry + * the program actually resolved rather than from whatever sits at the + * install's top level (Copilot, #1965 r1 — a nested copy at a different + * version would otherwise be compared against a top-level entry that is + * absent, or worse, present and aligned, and the real pair would pass). */ export function classifyModulePath(rel) { const parts = rel.split("/"); @@ -324,13 +332,13 @@ export function classifyModulePath(rel) { // `node_modules/.bin/…`, `node_modules/.package-lock.json`: npm's own // bookkeeping, not a package. if (!head || head.startsWith(".")) return null; - const name = head.startsWith("@") - ? parts[last + 2] && `${head}/${parts[last + 2]}` - : head; + const scoped = head.startsWith("@"); + const name = scoped ? parts[last + 2] && `${head}/${parts[last + 2]}` : head; if (!name) return null; // a bare `node_modules/@scope` path names no package return { installRoot: first === 0 ? "." : parts.slice(0, first).join("/"), name, + entryPath: parts.slice(first, last + (scoped ? 3 : 2)).join("/"), }; } @@ -340,9 +348,15 @@ export function classifyModulePath(rel) { * type in front of the type checker. * * `programs` is an iterable of `{ label, files }`, one per leaf tsconfig project, - * `files` being repo-relative `node_modules` paths. Returns a Map keyed by - * package name, each value `{ installRoots, programs }` naming where it was seen - * — the diagnostic a failure needs. + * `files` being repo-relative `node_modules` paths. Returns + * `Map>>>` — the + * co-occurrences themselves, not a flattened name list. + * + * Keeping the structure is what lets the caller compare only the copies that met + * (Copilot, #1965 r1). Flattening to names hands the comparison a package that + * is aligned wherever it co-occurs, and a third install holding a different + * version of it then fails the guard even though no program ever loads that + * copy — with a diagnostic naming an install that was never involved. * * The two-install test is applied **within one program**, not across the run: a * package reached from the root install in web's program and from the client @@ -362,20 +376,20 @@ export function classifyModulePath(rel) { export function crossInstallPackages(programs) { const found = new Map(); for (const { label, files } of programs) { - const roots = new Map(); // name -> Set(installRoot), within THIS program + // Within THIS program: name -> installRoot -> the entry paths loaded from it. + const seen = new Map(); for (const rel of files) { const hit = classifyModulePath(rel); if (!hit) continue; - if (!roots.has(hit.name)) roots.set(hit.name, new Set()); - roots.get(hit.name).add(hit.installRoot); + if (!seen.has(hit.name)) seen.set(hit.name, new Map()); + const byRoot = seen.get(hit.name); + if (!byRoot.has(hit.installRoot)) byRoot.set(hit.installRoot, new Set()); + byRoot.get(hit.installRoot).add(hit.entryPath); } - for (const [name, installRoots] of roots) { - if (installRoots.size < 2) continue; - if (!found.has(name)) - found.set(name, { installRoots: new Set(), programs: new Set() }); - const entry = found.get(name); - for (const root of installRoots) entry.installRoots.add(root); - entry.programs.add(label); + for (const [name, byRoot] of seen) { + if (byRoot.size < 2) continue; // one install — nothing to relate + if (!found.has(name)) found.set(name, new Map()); + found.get(name).set(label, byRoot); } } return found; diff --git a/scripts/lib/tsc-program.test.mjs b/scripts/lib/tsc-program.test.mjs index fa9bb9e1..c76825ff 100644 --- a/scripts/lib/tsc-program.test.mjs +++ b/scripts/lib/tsc-program.test.mjs @@ -155,28 +155,46 @@ test("classifyModulePath: install root is the OUTERMOST node_modules, package th assert.deepEqual(classifyModulePath("node_modules/zod/index.d.ts"), { installRoot: ".", name: "zod", + entryPath: "node_modules/zod", }); assert.deepEqual( classifyModulePath("clients/web/node_modules/zod/index.d.ts"), - { installRoot: "clients/web", name: "zod" }, + { + installRoot: "clients/web", + name: "zod", + entryPath: "node_modules/zod", + }, ); // A scoped package keeps both segments. assert.deepEqual( classifyModulePath("node_modules/@modelcontextprotocol/sdk/dist/x.d.ts"), - { installRoot: ".", name: "@modelcontextprotocol/sdk" }, + { + installRoot: ".", + name: "@modelcontextprotocol/sdk", + entryPath: "node_modules/@modelcontextprotocol/sdk", + }, ); // A NESTED copy folds onto its outermost install: npm resolving a transitive - // conflict inside one install is routine, not a cross-install skew, and the - // lockfile comparison ignores it for the same reason. + // conflict inside one install is routine, not a cross-install skew. Its + // `entryPath` still names the copy itself, so the caller can price the version + // the program actually loaded (Copilot, #1965 r1). assert.deepEqual( classifyModulePath("node_modules/a/node_modules/zod/index.d.ts"), - { installRoot: ".", name: "zod" }, + { + installRoot: ".", + name: "zod", + entryPath: "node_modules/a/node_modules/zod", + }, ); assert.deepEqual( classifyModulePath( "clients/cli/node_modules/a/node_modules/@scope/b/index.d.ts", ), - { installRoot: "clients/cli", name: "@scope/b" }, + { + installRoot: "clients/cli", + name: "@scope/b", + entryPath: "node_modules/a/node_modules/@scope/b", + }, ); }); @@ -210,14 +228,11 @@ test("crossInstallPackages: two installs in ONE program is the whole test (#1965 }, ]); assert.deepEqual([...found.keys()], ["zod"]); - assert.deepEqual([...found.get("zod").installRoots].sort(), [ - ".", - "clients/web", - ]); - assert.deepEqual( - [...found.get("zod").programs], - ["clients/web/tsconfig.test.json"], - ); + const byRoot = found.get("zod").get("clients/web/tsconfig.test.json"); + assert.deepEqual([...byRoot.keys()].sort(), [".", "clients/web"]); + // The entry paths ride along so the caller can price each copy from its own + // lockfile entry (Copilot, #1965 r1). + assert.deepEqual([...byRoot.get(".")], ["node_modules/zod"]); }); test("crossInstallPackages: two installs across SEPARATE programs is not a candidate", () => { @@ -252,8 +267,26 @@ test("crossInstallPackages: every program that saw both copies is recorded", () { label: "web/test", files: both }, { label: "cli/src", files: ["node_modules/zod/index.d.ts"] }, ]); - assert.deepEqual([...found.get("zod").programs].sort(), [ + assert.deepEqual([...found.get("zod").keys()].sort(), [ "web/app", "web/test", ]); }); + +test("crossInstallPackages: a nested copy is kept as its own entry path (Copilot, #1965 r1)", () => { + // Folded onto the root install for candidacy, but recorded at the path it was + // loaded from so its real version can be read. + const found = crossInstallPackages([ + { + label: "p", + files: [ + "node_modules/a/node_modules/zod/index.d.ts", + "clients/web/node_modules/zod/index.d.ts", + ], + }, + ]); + assert.deepEqual( + [...found.get("zod").get("p").get(".")], + ["node_modules/a/node_modules/zod"], + ); +}); diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs index eb410167..32819af9 100644 --- a/scripts/verify-dep-lockstep.main.test.mjs +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -44,14 +44,18 @@ const typescriptDir = path.dirname( ), ); -/** A lockfileVersion 3 lockfile, including the `""` root entry npm always writes. */ +/** + * A lockfileVersion 3 lockfile, including the `""` root entry npm always writes. + * A key already containing `node_modules/` is used verbatim, so a nested install + * path (`node_modules/outer/node_modules/inner`) can be expressed. + */ const lock = (deps) => ({ lockfileVersion: 3, packages: { "": { name: "fixture" }, ...Object.fromEntries( Object.entries(deps).map(([name, version]) => [ - `node_modules/${name}`, + name.includes("node_modules/") ? name : `node_modules/${name}`, { version }, ]), ), @@ -76,6 +80,8 @@ function makeFixture({ webScripts, rawWebLock, webTsconfig, + rootNestedInner, + cliDeps, } = {}) { // realpath matters: on macOS `tmpdir()` is `/var/...`, a symlink to // `/private/var/...`. The guard only runs `main()` when `import.meta.url` @@ -161,9 +167,54 @@ function makeFixture({ } stub("", root, "solo", "export type Solo = string;\n"); - write("package-lock.json", lock(root)); + // The root reaches `inner` only through a copy NESTED under `outer`, at a + // version its top-level entry does not carry. npm's own conflict resolution, + // and the case that must still be priced from the entry the program resolved. + const rootLockDeps = { ...root }; + if (rootNestedInner) { + stub( + "node_modules/outer/", + { inner: rootNestedInner }, + "inner", + "export type Inner = number;\n", + ); + rootLockDeps["node_modules/outer/node_modules/inner"] = rootNestedInner; + } + + write("package-lock.json", lock(rootLockDeps)); write("clients/web/package-lock.json", rawWebLock ?? lock(web)); + // A second client, to prove a third install's copy is not dragged into a + // comparison it never took part in. Its program spans only its own install. + if (cliDeps) { + write("clients/cli/package.json", { + name: "cli", + scripts: { typecheck: "tsc --noEmit -p tsconfig.json" }, + }); + write("clients/cli/tsconfig.json", { + compilerOptions: { + noEmit: true, + module: "esnext", + target: "esnext", + moduleResolution: "bundler", + types: [], + }, + include: ["src/**/*.ts"], + }); + write( + "clients/cli/src/main.ts", + 'import type { Outer } from "outer";\nexport type FromCli = Outer;\n', + ); + stub( + "clients/cli/", + cliDeps, + "outer", + 'import type { Inner } from "inner";\nexport type Outer = Inner;\n', + ); + stub("clients/cli/", cliDeps, "inner", "export type Inner = number;\n"); + write("clients/cli/package-lock.json", lock(cliDeps)); + } + // The guard resolves its repo root from its own location, so it has to live // inside the fixture; its `lib/` imports come along. mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true }); @@ -236,10 +287,10 @@ test("main: exits 1 and names the skewed package and every holder", () => { const { status, out } = runGuard(dir); assert.equal(status, 1, out); assert.match(out, /\bouter\b/); - assert.match(out, /1\.0\.0\s+\(\.\)/); - assert.match(out, /1\.2\.3\s+\(clients\/web\)/); + assert.match(out, /1\.0\.0\s+\(\.\/node_modules\/outer\)/); + assert.match(out, /1\.2\.3\s+\(clients\/web\/node_modules\/outer\)/); // The program that saw both copies is named, so the claim is checkable. - assert.match(out, /seen in clients\/web\/tsconfig\.json/); + assert.match(out, /in clients\/web\/tsconfig\.json/); // `inner` agrees, so it must not be reported. assert.doesNotMatch(out, /^\s+inner$/m); }, @@ -263,8 +314,8 @@ test("main: a package reached only through another package's .d.ts is caught (#1 const { status, out } = runGuard(dir); assert.equal(status, 1, out); assert.match(out, /^\s+inner$/m); - assert.match(out, /4\.0\.0\s+\(\.\)/); - assert.match(out, /4\.5\.6\s+\(clients\/web\)/); + assert.match(out, /4\.0\.0\s+\(\.\/node_modules\/inner\)/); + assert.match(out, /4\.5\.6\s+\(clients\/web\/node_modules\/inner\)/); }, ); }); @@ -284,6 +335,45 @@ test("main: a package that reaches the program from ONE install is not a candida ); }); +test("main: a third install's copy is not dragged into a comparison it never joined (Copilot, #1965 r1)", () => { + // `clients/cli` holds `outer` at a different version, but its own program is + // the only one that loads that copy and no second install meets it there. + // Flattening the candidates to names would compare cli against web's aligned + // pair and fail — naming an install that never took part. + withFixture( + { cliDeps: { ...ALIGNED, outer: "9.9.9", inner: "9.9.9" } }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 0, out); + assert.doesNotMatch(out, /clients\/cli/); + }, + ); +}); + +test("main: a nested copy is priced from its own lockfile entry (Copilot, #1965 r1)", () => { + // The root reaches `inner` only through `node_modules/outer/node_modules/inner` + // at 9.9.9, while its top-level entry still reads 4.5.6 — the version web + // holds. Pricing the copy from the top-level entry would report the pair as + // agreeing and let a real 9.9.9-vs-4.5.6 program through. + withFixture( + { + rootDeps: { ...ALIGNED, outer: "1.0.0" }, + webDeps: ALIGNED, + rootNestedInner: "9.9.9", + }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /^\s+inner$/m); + assert.match( + out, + /9\.9\.9\s+\(\.\/node_modules\/outer\/node_modules\/inner\)/, + ); + assert.match(out, /4\.5\.6\s+\(clients\/web\/node_modules\/inner\)/); + }, + ); +}); + test("main: exits 1 when a client names no tsconfig project", () => { // No `typecheck` script and no `tsconfig.json` references: none of that // client's programs is measured, so a skew reaching only them would pass. diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 3fe3a40e..05d9277a 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -98,18 +98,23 @@ const repoRoot = path.resolve( const TOLERATED_SKEW = new Map(); /** - * Top-level installed versions of every package in a parsed lockfile, keyed by - * package name. Only `node_modules/` entries count — a *nested* - * `node_modules/a/node_modules/b` is npm resolving a transitive conflict inside - * one install, which is routine and not what this guard is about (the same - * asymmetry `classifyModulePath` applies to a resolved path). + * Installed versions in a parsed lockfile, keyed by the **install path** npm + * writes — `node_modules/zod`, `node_modules/a/node_modules/zod`. + * + * Keyed by path rather than by package name so a version is read from the exact + * copy a program resolved (Copilot, #1965 r1). Reading only top-level entries + * would mean a nested copy that entered the program got compared against + * whatever sits at the install's top level — a different version, or nothing at + * all — and a real pair could pass. Nested copies are still not a *candidate* on + * their own (`classifyModulePath` folds them onto their outermost install); this + * is about pricing a copy correctly once a program has loaded it. */ -export function topLevelLockVersions(lock) { +export function lockVersionsByPath(lock) { const versions = new Map(); for (const [entryPath, entry] of Object.entries(lock?.packages ?? {})) { - const m = /^node_modules\/(@[^/]+\/[^/]+|[^@/][^/]*)$/.exec(entryPath); - if (!m || typeof entry?.version !== "string") continue; - versions.set(m[1], entry.version); + if (!entryPath.startsWith("node_modules/")) continue; + if (typeof entry?.version !== "string") continue; + versions.set(entryPath, entry.version); } return versions; } @@ -120,7 +125,7 @@ export function topLevelLockVersions(lock) { * the root project. * * This is checked rather than tolerated because the gate is deny-by-default and - * `topLevelLockVersions` returns an empty map for anything else. An unreadable + * `lockVersionsByPath` returns an empty map for anything else. An unreadable * lockfile would otherwise contribute no holders, and a real skew among the * remaining installs would be reported as aligned — the gate failing *open*, * which is the one way it must never fail (Copilot, #1962). A v1 lockfile @@ -141,22 +146,48 @@ export function hasReadableLockShape(lock) { } /** - * Find candidate packages that resolve to more than one version across the - * installs. `installs` is an array of `{ dir, versions }`. Returns one entry per - * skewed package, sorted by name, each listing the version each install holds. - * Packages present in fewer than two installs cannot skew and are skipped. + * Price each co-occurrence and keep the ones whose copies disagree. + * + * `found` is {@link crossInstallPackages}' output — + * `Map>>>` — and `versions` + * maps an install dir to that install's {@link lockVersionsByPath}. A package is + * skewed when the copies **one program** loaded, from two different installs, + * carry more than one version. Only the installs that actually met in that + * program are compared: a third install's copy that no program loads is not + * evidence of anything (Copilot, #1965 r1). + * + * Returns `{ skewed, unresolved }`, sorted by name. `unresolved` names a copy + * whose lockfile entry is missing — the tree and the lockfile disagree, so the + * comparison cannot be trusted and the caller must fail rather than skip it. */ -export function findSkew(candidates, installs) { +export function findSkew(found, versions) { const skewed = []; - for (const name of [...candidates].sort()) { - const holders = installs - .filter(({ versions }) => versions.has(name)) - .map(({ dir, versions }) => ({ dir, version: versions.get(name) })); - if (holders.length < 2) continue; - const distinct = new Set(holders.map((h) => h.version)); - if (distinct.size > 1) skewed.push({ name, holders }); + const unresolved = []; + for (const name of [...found.keys()].sort()) { + const occurrences = []; + for (const [program, byRoot] of found.get(name)) { + const holders = []; + for (const [dir, entryPaths] of byRoot) + for (const entryPath of entryPaths) { + const version = versions.get(dir)?.get(entryPath); + if (version === undefined) unresolved.push({ name, dir, entryPath }); + else holders.push({ dir, entryPath, version }); + } + if (new Set(holders.map((h) => h.version)).size > 1) + occurrences.push({ program, holders }); + } + if (occurrences.length > 0) skewed.push({ name, occurrences }); } - return skewed; + return { skewed, unresolved }; +} + +/** Every holder across a skewed package's occurrences, deduped by install + path. */ +export function skewHolders(entry) { + const byKey = new Map(); + for (const { holders } of entry.occurrences) + for (const holder of holders) + byKey.set(`${holder.dir}|${holder.entryPath}`, holder); + return [...byKey.values()]; } /** @@ -181,7 +212,7 @@ export function majorOf(version) { export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { const isTolerated = (s) => { if (!tolerated.has(s.name)) return false; - const majors = new Set(s.holders.map((h) => majorOf(h.version))); + const majors = new Set(skewHolders(s).map((h) => majorOf(h.version))); return majors.size === 1 && !majors.has(null); }; return { @@ -343,8 +374,6 @@ export function main() { ); process.exit(1); } - const candidates = new Set(found.keys()); - const dirs = installDirs(); // A missing lockfile is a failure, not a skipped install: dropping one would @@ -398,32 +427,53 @@ export function main() { process.exit(1); } - const installs = locks.map(({ dir, lock }) => ({ - dir, - versions: topLevelLockVersions(lock), - })); + const versions = new Map( + locks.map(({ dir, lock }) => [dir, lockVersionsByPath(lock)]), + ); - const { failures, ignored } = partitionSkew(findSkew(candidates, installs)); + const { skewed, unresolved } = findSkew(found, versions); + + // A copy the program loaded but the lockfile doesn't list means the installed + // tree and the lockfile disagree — the comparison would be reading a version + // that isn't the one on disk, so refuse it rather than skip the copy. + if (unresolved.length > 0) { + console.error( + `verify:dep-lockstep — ${unresolved.length} resolved package(s) have no lockfile entry:\n`, + ); + for (const { name, dir, entryPath } of unresolved) + console.error(` ${name} ${dir}/${entryPath}`); + console.error( + "\nThe program loaded these, so their versions decide the comparison — but the install's lockfile" + + "\ndoesn't list them, which means the tree and the lockfile disagree. Run `npm install` at the repo" + + "\nroot to re-sync every install.", + ); + process.exit(1); + } + + const { failures, ignored } = partitionSkew(skewed); if (failures.length > 0) { console.error( `verify:dep-lockstep — ${failures.length} ${failures.length === 1 ? "dependency resolves" : "dependencies resolve"} to different versions across installs:\n`, ); let anyListed = false; - for (const { name, holders } of failures) { + for (const failure of failures) { // A package already on the allowlist reached here only by skewing across // a MAJOR boundary, so say that rather than advising an entry that exists. - const listed = TOLERATED_SKEW.has(name); + const listed = TOLERATED_SKEW.has(failure.name); anyListed ||= listed; console.error( - ` ${name}${listed ? " (allowlisted — but this is a MAJOR skew)" : ""}`, + ` ${failure.name}${listed ? " (allowlisted — but this is a MAJOR skew)" : ""}`, ); - for (const { dir, version } of holders) - console.error(` ${version} (${dir})`); - // Which program saw both copies is the whole reason the package is a - // candidate — without it the reader has no way to check the claim. - for (const label of [...found.get(name).programs].sort()) - console.error(` seen in ${label}`); + // Report per program: which copies met, and where. The program is the + // whole reason the package is a candidate, and naming only the versions + // would leave the reader unable to check the claim — or to tell a nested + // copy from the install's top-level one. + for (const { program, holders } of failure.occurrences) { + console.error(` in ${program}`); + for (const { dir, entryPath, version } of holders) + console.error(` ${version} (${dir}/${entryPath})`); + } } console.error( "\nEach of these reaches a single `tsc` program from two installs (a client's own sources resolve" + @@ -447,7 +497,7 @@ export function main() { const note = ignored.length > 0 ? `, ${ignored.length} tolerated` : ""; console.log( - `verify:dep-lockstep — OK: ${candidates.size} install-crossing dependencies agree across ${installs.length} installs${note} ` + + `verify:dep-lockstep — OK: ${found.size} install-crossing dependencies agree across ${dirs.length} installs${note} ` + `(derived from ${programs.length} tsc programs).`, ); } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 25a643ab..edcbbc4f 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -15,9 +15,9 @@ import { clientProjects, findSkew, hasReadableLockShape, + lockVersionsByPath, majorOf, partitionSkew, - topLevelLockVersions, } from "./verify-dep-lockstep.mjs"; test("clientProjects: a `typecheck` script's projects win over references", () => { @@ -60,11 +60,11 @@ test("clientProjects: a NEUTERED project still contributes its program (#1965)", ); }); -test("topLevelLockVersions: nested duplicates are ignored", () => { - // A nested `node_modules/a/node_modules/b` is npm resolving a transitive - // conflict *inside* one install — routine, and not the cross-install skew - // this guard is about (`cosmiconfig`'s yaml@1 alongside the top-level yaml@2 - // is the live example). +test("lockVersionsByPath: keyed by install path, nested entries included (Copilot, #1965 r1)", () => { + // Keyed by PATH, not by package name: a nested copy that entered a program has + // to be priced from its own entry. Reading only `node_modules/` would + // compare it against the install's top-level copy — a different version, or + // none — and a real pair could pass. const lock = { packages: { "": { name: "root" }, @@ -75,18 +75,173 @@ test("topLevelLockVersions: nested duplicates are ignored", () => { "node_modules/no-version": { resolved: "https://example.test/x.tgz" }, }, }; - assert.deepEqual([...topLevelLockVersions(lock)].sort(), [ - ["@modelcontextprotocol/client", "2.0.0-beta.5"], - ["yaml", "2.9.0"], - ["zod", "4.4.3"], + assert.deepEqual([...lockVersionsByPath(lock)].sort(), [ + ["node_modules/@modelcontextprotocol/client", "2.0.0-beta.5"], + ["node_modules/cosmiconfig/node_modules/yaml", "1.10.3"], + ["node_modules/yaml", "2.9.0"], + ["node_modules/zod", "4.4.3"], ]); }); -test("topLevelLockVersions: a malformed or empty lockfile yields nothing", () => { +test("lockVersionsByPath: a malformed or empty lockfile yields nothing", () => { // Safe as a pure helper *because* `hasReadableLockShape` rejects these before // any comparison — an empty map reaching `findSkew` is the fail-open path. for (const lock of [undefined, null, {}, { packages: {} }]) - assert.equal(topLevelLockVersions(lock).size, 0); + assert.equal(lockVersionsByPath(lock).size, 0); +}); + +/** `crossInstallPackages`-shaped input: name → program → install → entry paths. */ +const occurrence = (name, program, byRoot) => + new Map([ + [ + name, + new Map([ + [ + program, + new Map( + Object.entries(byRoot).map(([dir, paths]) => [dir, new Set(paths)]), + ), + ], + ]), + ], + ]); + +const lockPaths = (byDir) => + new Map( + Object.entries(byDir).map(([dir, entries]) => [ + dir, + new Map(Object.entries(entries)), + ]), + ); + +test("findSkew: reports the copies one program loaded, with their paths", () => { + const { skewed, unresolved } = findSkew( + occurrence("zod", "clients/web/tsconfig.test.json", { + ".": ["node_modules/zod"], + "clients/web": ["node_modules/zod"], + }), + lockPaths({ + ".": { "node_modules/zod": "4.3.6" }, + "clients/web": { "node_modules/zod": "4.4.3" }, + }), + ); + assert.deepEqual(unresolved, []); + assert.deepEqual(skewed, [ + { + name: "zod", + occurrences: [ + { + program: "clients/web/tsconfig.test.json", + holders: [ + { dir: ".", entryPath: "node_modules/zod", version: "4.3.6" }, + { + dir: "clients/web", + entryPath: "node_modules/zod", + version: "4.4.3", + }, + ], + }, + ], + }, + ]); +}); + +test("findSkew: only the installs that MET in a program are compared (Copilot, #1965 r1)", () => { + // `clients/cli` holds a different zod, but no program loads it beside another + // copy — nothing has to relate the two, so it is not a finding, and naming cli + // in a diagnostic about web's program would be wrong as well as noisy. + const { skewed } = findSkew( + occurrence("zod", "clients/web/tsconfig.test.json", { + ".": ["node_modules/zod"], + "clients/web": ["node_modules/zod"], + }), + lockPaths({ + ".": { "node_modules/zod": "4.4.3" }, + "clients/web": { "node_modules/zod": "4.4.3" }, + "clients/cli": { "node_modules/zod": "4.3.6" }, + }), + ); + assert.deepEqual(skewed, []); +}); + +test("findSkew: a nested copy is priced from its own entry (Copilot, #1965 r1)", () => { + // The root loaded zod through `a`'s nested copy. Pricing it from the root's + // TOP-LEVEL entry (4.4.3, aligned with web) would report the pair as agreeing. + const { skewed } = findSkew( + occurrence("zod", "clients/web/tsconfig.test.json", { + ".": ["node_modules/a/node_modules/zod"], + "clients/web": ["node_modules/zod"], + }), + lockPaths({ + ".": { + "node_modules/zod": "4.4.3", + "node_modules/a/node_modules/zod": "3.1.0", + }, + "clients/web": { "node_modules/zod": "4.4.3" }, + }), + ); + assert.deepEqual( + skewed[0].occurrences[0].holders.map((h) => `${h.dir}:${h.version}`), + [".:3.1.0", "clients/web:4.4.3"], + ); +}); + +test("findSkew: agreement is not skew", () => { + const { skewed, unresolved } = findSkew( + occurrence("zod", "p", { + ".": ["node_modules/zod"], + "clients/web": ["node_modules/zod"], + }), + lockPaths({ + ".": { "node_modules/zod": "4.4.3" }, + "clients/web": { "node_modules/zod": "4.4.3" }, + }), + ); + assert.deepEqual(skewed, []); + assert.deepEqual(unresolved, []); +}); + +test("findSkew: a copy with no lockfile entry is reported, not skipped", () => { + // Skipping it would drop a holder from the comparison and could report a real + // skew as agreement — the gate failing open. + const { skewed, unresolved } = findSkew( + occurrence("zod", "p", { + ".": ["node_modules/zod"], + "clients/web": ["node_modules/zod"], + }), + lockPaths({ ".": { "node_modules/zod": "4.4.3" }, "clients/web": {} }), + ); + assert.deepEqual(unresolved, [ + { name: "zod", dir: "clients/web", entryPath: "node_modules/zod" }, + ]); + assert.deepEqual(skewed, []); +}); + +test("findSkew: results are sorted by package name", () => { + const found = new Map([ + ...occurrence("zod", "p", { + ".": ["node_modules/zod"], + "clients/web": ["node_modules/zod"], + }), + ...occurrence("hono", "p", { + ".": ["node_modules/hono"], + "clients/web": ["node_modules/hono"], + }), + ]); + const { skewed } = findSkew( + found, + lockPaths({ + ".": { "node_modules/zod": "1.0.0", "node_modules/hono": "1.0.0" }, + "clients/web": { + "node_modules/zod": "2.0.0", + "node_modules/hono": "2.0.0", + }, + }), + ); + assert.deepEqual( + skewed.map((s) => s.name), + ["hono", "zod"], + ); }); test("hasReadableLockShape: only a v2+ packages table with a root entry (Copilot, #1962)", () => { @@ -124,89 +279,32 @@ test("hasReadableLockShape: only a v2+ packages table with a root entry (Copilot assert.equal(hasReadableLockShape(lock), false, JSON.stringify(lock)); }); -test("findSkew: reports a package held at two versions", () => { - const installs = [ - { dir: ".", versions: new Map([["zod", "4.3.6"]]) }, - { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, - { dir: "clients/cli", versions: new Map([["zod", "4.4.3"]]) }, - ]; - assert.deepEqual(findSkew(new Set(["zod"]), installs), [ - { - name: "zod", - holders: [ - { dir: ".", version: "4.3.6" }, - { dir: "clients/web", version: "4.4.3" }, - { dir: "clients/cli", version: "4.4.3" }, - ], - }, - ]); -}); - -test("findSkew: agreement and single-install packages are not skew", () => { - const installs = [ - { - dir: ".", - versions: new Map([ - ["zod", "4.4.3"], - ["express", "5.2.1"], - ]), - }, - { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, - ]; - // `express` lives in one install only, so it cannot skew — a package absent - // from a client is not a finding. - assert.deepEqual(findSkew(new Set(["zod", "express"]), installs), []); -}); - -test("findSkew: a candidate in no lockfile is inert", () => { - // `@inspector/core` is a build-time alias, not a package; the scan picks it - // up and it must drop out here rather than error. - const installs = [ - { dir: ".", versions: new Map([["zod", "4.4.3"]]) }, - { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, - ]; - assert.deepEqual(findSkew(new Set(["@inspector/core"]), installs), []); -}); - -test("findSkew: results are sorted by package name", () => { - const installs = [ - { - dir: ".", - versions: new Map([ - ["zod", "1.0.0"], - ["hono", "1.0.0"], - ]), - }, - { - dir: "clients/web", - versions: new Map([ - ["zod", "2.0.0"], - ["hono", "2.0.0"], - ]), - }, - ]; - assert.deepEqual( - findSkew(new Set(["zod", "hono"]), installs).map((s) => s.name), - ["hono", "zod"], - ); -}); - test("partitionSkew: the allowlist is by name, not by version pair", () => { // So an ordinary patch float within a tolerated package does not churn the // allowlist, while any *unlisted* package that starts skewing still fails. const skewed = [ { name: "react", - holders: [ - { dir: ".", version: "19.2.7" }, - { dir: "clients/web", version: "19.2.8" }, + occurrences: [ + { + program: "p", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, ], }, { name: "zod", - holders: [ - { dir: ".", version: "4.3.6" }, - { dir: "clients/web", version: "4.4.3" }, + occurrences: [ + { + program: "p", + holders: [ + { dir: ".", version: "4.3.6" }, + { dir: "clients/web", version: "4.4.3" }, + ], + }, ], }, ]; @@ -223,7 +321,14 @@ test("partitionSkew: the allowlist is by name, not by version pair", () => { }); test("partitionSkew: deny by default — nothing tolerated fails everything", () => { - const skewed = [{ name: "zod", holders: [{ dir: ".", version: "1.0.0" }] }]; + const skewed = [ + { + name: "zod", + occurrences: [ + { program: "p", holders: [{ dir: ".", version: "1.0.0" }] }, + ], + }, + ]; assert.equal(partitionSkew(skewed, new Map()).failures.length, 1); }); @@ -235,18 +340,28 @@ test("partitionSkew: the allowlist tolerates skew only within a major (Copilot, const withinMajor = [ { name: "react", - holders: [ - { dir: ".", version: "19.2.7" }, - { dir: "clients/web", version: "19.2.8" }, + occurrences: [ + { + program: "p", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, ], }, ]; const acrossMajor = [ { name: "react", - holders: [ - { dir: ".", version: "18.3.1" }, - { dir: "clients/web", version: "19.2.8" }, + occurrences: [ + { + program: "p", + holders: [ + { dir: ".", version: "18.3.1" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, ], }, ]; @@ -261,9 +376,14 @@ test("partitionSkew: an unparseable version can't be proven same-major, so it fa const skewed = [ { name: "react", - holders: [ - { dir: ".", version: "19.2.7" }, - { dir: "clients/web", version: "next" }, + occurrences: [ + { + program: "p", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "next" }, + ], + }, ], }, ]; From 73913ef09ff7b63992cb9863ebe41d858418eeca Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 16 Aug 2026 18:11:29 -0400 Subject: [PATCH 57/58] fix(web): read modern tasks support from the extension map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEP-2663 moved task support from `capabilities.tasks` to the negotiated `capabilities.extensions["io.modelcontextprotocol/tasks"]`, but the Connection Info modal's capability grid tested only for the top-level key. A modern server that advertised the extension in `server/discover` therefore showed "Tasks ✗" while the very same extension id was listed under "Server Extensions" two sections below. Server capability rows now fall back to an extension id for the capabilities the modern era expresses that way (tasks is the only one today), gated on the negotiated era so a legacy connection is still judged purely on its `initialize` capabilities. Keyed the same way `InspectorClient.isTasksExtensionNegotiated()` gates the Tasks tab, so the checkmark and the tab can't disagree. Signed-off-by: cliffhall --- .../ConnectionInfoContent.stories.tsx | 8 ++ .../ConnectionInfoContent.test.tsx | 103 ++++++++++++++++++ .../ConnectionInfoContent.tsx | 52 ++++++++- 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx index c6f1e598..eaf54cee 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx @@ -77,6 +77,14 @@ export const ModernEra: Story = { }, }, }, + // SEP-2663 moved task support to the extension map, so the Tasks capability + // row must read it there on a modern connection — otherwise a tasks-capable + // server shows ✗ while listing the extension below (#1887). + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const tasksRow = canvas.getByText("Tasks"); + await expect(tasksRow.previousElementSibling).toHaveTextContent("✓"); + }, }; // A modern server that omitted the optional `_meta` serverInfo stamp (#1772). diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index f9f3d2fa..db49986b 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -31,6 +31,21 @@ const fullClientCaps: ClientCapabilities = { experimental: {}, }; +// The glyphs `CapabilityItem` renders beside each capability label. +const SUPPORTED_MARK = "✓"; +const UNSUPPORTED_MARK = "✗"; + +/** + * Read the ✓/✗ a capability row is showing. The mark is the label's preceding + * sibling inside the row `Group`, so asserting on it — rather than on the + * label's mere presence — is what distinguishes "supported" from "listed". + */ +function capabilityMark(label: string): string | undefined { + return ( + screen.getByText(label).previousElementSibling?.textContent ?? undefined + ); +} + describe("ConnectionInfoContent", () => { it("renders server implementation fields under the heading", () => { renderWithMantine( @@ -181,6 +196,94 @@ describe("ConnectionInfoContent", () => { expect(screen.getByText("Sampling")).toBeInTheDocument(); }); + it("marks Tasks supported on a modern connection that advertises the tasks extension (#1887)", () => { + renderWithMantine( + , + ); + expect(capabilityMark("Tasks")).toBe(SUPPORTED_MARK); + }); + + it("still marks Tasks unsupported on a modern connection without the extension (#1887)", () => { + renderWithMantine( + , + ); + expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK); + }); + + it("does not read the tasks extension on a legacy connection (#1887)", () => { + renderWithMantine( + , + ); + // Legacy tasks support is `capabilities.tasks`, which this server omits — + // an extension key alone must not turn the row green. + expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK); + }); + + it("marks Tasks supported on a legacy connection from capabilities.tasks", () => { + renderWithMantine( + , + ); + expect(capabilityMark("Tasks")).toBe(SUPPORTED_MARK); + }); + + it("does not extension-promote a capability other than tasks (#1887)", () => { + renderWithMantine( + , + ); + expect(capabilityMark("Prompts")).toBe(UNSUPPORTED_MARK); + expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK); + }); + it("renders instructions when present", () => { renderWithMantine( +> = { + tasks: TASKS_EXTENSION_KEY, +}; + +function isCapabilityPresent( + capabilities: Record, + key: CapabilityKey, +): boolean { + return key in capabilities && capabilities[key] != null; +} + function getCapabilityEntries( capabilities: Record, knownKeys: CapabilityKey[], ): { capability: CapabilityKey; supported: boolean }[] { return knownKeys.map((key) => ({ capability: key, - supported: key in capabilities && capabilities[key] != null, + supported: isCapabilityPresent(capabilities, key), })); } +/** + * Server-side capability entries. Same presence rule as the client column, + * plus the modern extension fallback above — gated on the negotiated era so a + * legacy connection is still judged purely on its `initialize` capabilities. + */ +function getServerCapabilityEntries( + capabilities: ServerCapabilities, + era: ProtocolEra | undefined, +): { capability: CapabilityKey; supported: boolean }[] { + const modern = isModernEra(era); + return SERVER_CAPABILITY_KEYS.map((key) => { + const extensionKey = MODERN_EXTENSION_BACKED_CAPABILITIES[key]; + const viaExtension = + modern && + extensionKey !== undefined && + capabilities.extensions?.[extensionKey] != null; + return { + capability: key, + supported: isCapabilityPresent(capabilities, key) || viaExtension, + }; + }); +} + export function ConnectionInfoContent({ initializeResult, serverInfoReported = true, @@ -229,7 +277,7 @@ export function ConnectionInfoContent({ ? serverInfo.version?.trim() || "—" : SERVER_INFO_NOT_REPORTED_LABEL; - const serverCaps = getCapabilityEntries(capabilities, SERVER_CAPABILITY_KEYS); + const serverCaps = getServerCapabilityEntries(capabilities, protocolEra); const clientCaps = getCapabilityEntries( clientCapabilities, CLIENT_CAPABILITY_KEYS, From 0b54cdc4e1794743a5d60ec3d5a560f1cfbd12a0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 16 Aug 2026 18:25:49 -0400 Subject: [PATCH 58/58] test(web): use the object shape for the legacy tasks capability `ServerTasksCapability`'s `list`/`cancel` are objects, not booleans, so the legacy-tasks fixture failed `tsc -b` in CI. A stale incremental build cache hid it locally. Signed-off-by: cliffhall --- .../ConnectionInfoContent/ConnectionInfoContent.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index db49986b..0ebb740f 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -254,7 +254,9 @@ describe("ConnectionInfoContent", () => { ...fullResult, capabilities: { ...fullResult.capabilities, - tasks: { list: true, cancel: true }, + // `ServerTasksCapability`'s sub-capabilities are objects, not + // booleans — `{}` is the "supported, no sub-options" shape. + tasks: { list: {}, cancel: {} }, }, }} clientCapabilities={fullClientCaps}