Commit graph

2,691 commits

Author SHA1 Message Date
cliffhall
b143dd6cbd fix(auth): address Copilot review round 3 on the secret-store PR
Nine findings (six inline, three suppressed), all taken. Two were serious.

**The memory fallback made the migrations destructive.** Both migrations —
mcp.json in the remote server, client.json in `node-persistence` — lift a
plaintext secret off disk, write it to the secret store, and then delete it
from the file. That trade is safe only while the store is at least as
durable as the disk, which held by construction while `InMemorySecretStore`
was a test double. Making it a production fallback broke it: on an
unmounted container a plain `GET /api/servers` would have moved a user's
existing secrets into RAM and lost them at exit — destroying data on a
read.

`SecretStore` gains an optional `isDurable()`, absent meaning durable, and
both migrations withhold the disk delete when it answers false (the value
is still loaded, so the session behaves normally). The production fallback
is now a named `SessionSecretStore` rather than the bare
`InMemorySecretStore`, so the suite's many doubles keep standing in for a
*working keychain* — which is what they are there to be — and only code
that deliberately chooses RAM in production says so.

**Round 2's per-call descriptor never reached anyone.** Both web servers
resolved it once at startup and baked it into `initialConfig`, which
`/api/config` serves unchanged — so the freshness fix was inert for its
only consumer. The route now takes a `secretStorageResolver` and calls it
per request.

The lock needed three more fixes, and they compound:

- **Release removed whatever directory was at the path.** A holder whose
  work outran the stale threshold would delete the *next* owner's lock,
  letting a third writer into a section two processes believed they held.
  Ownership is now stamped with a random token and release only removes a
  lock still carrying it.
- **The timeout fell through to an unlocked write** — under contention,
  the one moment another writer is provably active. It now throws
  `SecretFileLockTimeoutError`, which `set` is allowed to do.
- **The first write of every process bypassed the lock**, because the
  secrets directory does not exist yet and `mkdir` failed ENOENT. The
  parent is created first. First writes are exactly when two processes are
  most likely to start together.

Fixing the timeout exposed a fourth: with stale (10s) below timeout, a
waiter stole from a holder that was merely *slow* rather than dead —
reinstating the lost update. A holder now heartbeats its lock, so
"stale" means the holder is gone, and the timeout is what a live-but-slow
holder produces. Lock timings are injectable so the tests can exercise
contention, staleness, and timeout without spending the real intervals.

Also: the loose-permission state is carried into `SecretStorageInfo` as
`looseMode` and rendered as the caveat (it outranks the encryption caveat —
a file others can read is a live exposure, while "unencrypted" is a
property of a file only its owner can open), rather than living in a
startup log the browser cannot see; and `usableSecretStorage` validates
`pendingEncryption` and `looseMode` as the optional booleans/numbers they
are (`pendingEncryption: "false"` is truthy and would have printed advice
that is the opposite of the truth) and rejects file-only fields on a
keychain or memory descriptor.

Tests: both migrations against a session store with a durable control
alongside, so the guard is visibly the difference; `/api/config` serving a
changing resolver and falling back without one; lock ownership after a
steal, the timeout under a live holder, no-steal from a slow holder, and a
locked first write; `looseMode` through the descriptor, the caveat
ordering, and the tone; and five more rejected payload shapes. Full
`npm run ci` green — 5,704 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 13:19:56 -04:00
cliffhall
de7795ca54 fix(auth): address Copilot review round 2 on the secret-store PR
Seven findings, all taken.

- **The descriptor went stale after the upgrading write.** Round 1 made it
  read the file instead of the policy, but it was still computed once and
  cached beside the selection — so the first `set` under a newly-set
  passphrase encrypted the file while `/api/config` kept serving "still
  unencrypted" for the rest of the session, describing bytes this very
  process had just changed. The *selection* stays cached (the banner, the
  payload, and the store doing the writing must agree on which store it
  is); the file descriptor is re-derived per call, which costs one read of
  a small file per config fetch.

- **The mount check asked the wrong question.** Comparing a directory's
  `st_dev` against its parent's detects a mount *at* that path, but a
  volume mounted at `/home/node` leaves `/home/node/.mcp-inspector` an
  ordinary subdirectory sharing its parent's device — so a durable
  container setup was judged unmounted and demoted to session-only
  `memory`. It now reads `/proc/self/mountinfo` and asks whether the
  directory is *under* any mount other than `/`, which also catches the
  same-device bind mount no `st_dev` test can see. Where mountinfo is
  unreadable (macOS, Windows, a `/proc`-less container) it falls back to
  the device comparison, now against `/` rather than the parent.

- **A decrypted payload was cast, not checked.** GCM proves the bytes are
  authentic, not that they parse to the right shape. An array passes
  `typeof === "object"`, takes the named assignment, and then
  `JSON.stringify` drops every named property of an array — so `set` would
  resolve having written a file without the secret it was handed. Silent
  loss on the one operation whose entire contract is that it does not lose
  things. Both branches now validate a non-array object of string values,
  and a shape refusal is deliberately not rethrown as a key mismatch,
  which would send the user to fix a working passphrase.

- **The read-modify-write cycle was only serialized in-process.** A
  durable file is shared state and a second Inspector on the same box (a
  CLI run beside a web session) could read the same map and overwrite the
  first one's save, both reporting success. Mutations now take a
  `mkdir`-based lock. A lock left by a killed process is stolen after 10s
  — one `docker kill` must not brick secret writes permanently, which
  would be a worse failure than the race. If the lock cannot be created at
  all, the write proceeds unlocked rather than denying a real user a real
  secret to prevent a race with a process that may not exist. Reads do not
  lock: `writeStoreFile` is atomic, so a reader sees the old file or the
  new one, never a torn one.

- **The permission repair was fire-and-forget while the docs promised
  0600.** A file owned by another user, or on a read-only mount, stayed
  exposed while the footer described it as protected. It now verifies the
  resulting mode and warns, naming the actual mode, when it could not be
  tightened.

- **`Group`'s theme carried a domain-named `secretStorageFooter`
  variant**, which AGENTS.md reserves for app-wide primitive styling.
  Renamed to the structural `stickyModalFooter`; the secret-storage
  meaning stays in the element that owns it.

- The PR description still claimed the salt made a short passphrase safe,
  contradicting the corrected comment and README. Updated.

Tests: both mount layouts that motivated the change (mount-at and
mount-above) plus the sibling-prefix, space-escaped, first-run, and
no-mountinfo cases; the array and non-string refusals on both the
plaintext and the authentic-ciphertext paths (the latter through a
hand-built fixture, since the store's own writer cannot produce that
state); lock exclusion, stale-lock stealing, the un-lockable fallback, and
two independent store instances racing; the permission report in all three
states; and the descriptor no longer going stale within one process. Full
`npm run ci` green — 5,688 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 12:51:14 -04:00
cliffhall
3452a63101 fix(auth): address Copilot review on the secret-store PR
Six findings, all taken.

- The descriptor reported the *write policy*, not the file. Adding
  `MCP_INSPECTOR_SECRET_KEY` to an install that already has a plaintext
  file flipped `store.encrypted` immediately, so the banner and footer
  claimed "File (encrypted)" while the existing secrets sat readable
  until the next `set`. `FileSecretStore.readOnDiskEncryption()` now
  reads the envelope (never the payload, so it needs no passphrase) and
  the descriptor is built from that. The transitional state gets its own
  `pendingEncryption` flag, which changes the advice without softening
  the verdict — "set a passphrase" is the wrong thing to tell someone who
  already has, so the caveat names the pending write instead.

- The secrets file ignored the configured storage directory. A container
  mounting only that directory was judged unmounted, fell back to
  `memory`, and lost secrets the user had arranged to keep.
  `defaultSecretFilePath()` now follows `MCP_STORAGE_DIR` — the variable
  that already relocates OAuth tokens and `client.json` — between the
  explicit `MCP_INSPECTOR_SECRET_FILE` and the `~/.mcp-inspector`
  default. (The issue comment called it `MCP_INSPECTOR_STORAGE_DIR`; no
  such variable exists.)

- `version` was never checked before the plaintext branch, so a
  `{ version: 2, encryption: "none" }` file was read and silently
  rewritten as version 1, discarding whatever the newer format added —
  the same destroy-to-satisfy-an-additive-request case the encrypted
  branch already refuses. Checked first now, so the answer no longer
  depends on which encryption mode the newer writer chose.

- The doc comment claimed a per-file salt made "a short, memorable value
  safe to use". It does not: the salt defeats precomputation, not
  guessing, and the scrypt cost is deliberately a few milliseconds
  because it runs on every read and write. Both the comment and the
  README now say to use a high-entropy value, generated rather than
  chosen.

- `usableSecretStorage` validated only `kind`, so a partial
  `{ kind: "file" }` was cast through and — because a missing `plaintext`
  is falsy — rendered as the quiet, neutral *encrypted* file. That is the
  confident half-answer the guard exists to reject, produced by the
  absence of information rather than by any claim the backend made.
  `reason`, `durable`, and each kind's own required fields are checked.

- The footer's inline `Tooltip` carried four static props; extracted to a
  `CaveatTooltip` constant per the `.withProps()` rule.

Tests cover each: the policy/file disagreement in both directions and the
no-file-yet fallback, `MCP_STORAGE_DIR` and its precedence against
`MCP_INSPECTOR_SECRET_FILE`, the version refusal (asserting the bytes
survive it) split from the unrecognized-cipher refusal it used to be
conflated with, `readOnDiskEncryption` including the keyless and
unparseable cases, five new rejected descriptor shapes, and the changed
caveat. Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 12:26:33 -04:00
cliffhall
bc408f5869 feat(auth): file-backed and in-memory SecretStore for hosts with no keychain
Closes #1950

Before this, a box with no reachable OS keychain — the published
container (no D-Bus session, #1848), Android/Termux (no platform binary,
#1905), a minimal Linux install without libsecret — could not persist an
OAuth client secret or a stdio `env:` value at all. `KeyringSecretStore`
degraded into a store that silently holds nothing: `get` returned null,
`delete` no-opped, and `set` hard-failed with a 503. That is the correct
failure behavior, but it left the documented, supported way to run the
Inspector with no secret persistence whatsoever.

Adds the two missing implementations and, more importantly, the policy
that picks one and the surfaces that say which was picked.

- `FileSecretStore` — one `0600` JSON document at
  `~/.mcp-inspector/secrets.json`, encrypted with AES-256-GCM whenever
  `MCP_INSPECTOR_SECRET_KEY` is set (scrypt, per-file random salt). The
  whole map is encrypted as a unit rather than value-by-value so the
  account names — `${serverId}:${field}` — are hidden too; a per-value
  scheme would leave a readable index of which servers you hold a client
  secret for. A file that can no longer be decrypted reads as empty
  (matching the interface's read tolerance) but *refuses to be written*,
  because replacing a file of still-valid secrets to satisfy an additive
  request destroys data.

- `secret-store-selection.ts` — the policy.
  `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright;
  otherwise probe the keychain and, if unreachable, fall back loudly. The
  fallback is `memory` in a container whose secrets directory is not on a
  mount, and `file` otherwise — so mounting the volume the README already
  recommends for the catalog flips the same run to durable storage with
  no configuration, and an unmounted container gets the honest answer
  rather than a file `--rm` will discard. Cached per process so the
  banner, `/api/config`, and the store doing the writing cannot disagree.

- Automatic, but never silent. The downgrade prints at startup, rides
  `GET /api/config`, and is named in a permanent 32px footer at the
  bottom of both dialogs that accept a secret. A startup banner is seen
  once by whoever started the process; a toast is seen once; a
  dismissible banner is the thing a user dismisses before doing the work
  it describes. The plaintext and memory cases render their caveat inline
  in a warning tone — a warning you must hover to discover is not one.

- `SecretStoreUnavailableError` is now the base every store's `set`
  throws, so the routes translate one condition into a 503 instead of
  matching `KeychainUnavailableError` by name.

Docs: the container section of the root README gains a table of which
store you get and how to make secrets survive, plus the
`MCP_INSPECTOR_SECRET_KEY` upgrade/mismatch rules.

Tests: the existing interface contract block is extracted to
`secretStoreContract.ts` and run against all three implementations, so
none can drift on the availability contract. `FileSecretStore` is tested
against a real temp filesystem — mode, cross-instance round-trip, that
the ciphertext does not contain the plaintext, the read-modify-write
race, and every refusal path. Full `npm run ci` passes, per-file gate
included.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 12:06:31 -04:00
Cliff Hall
43525d9ea2
Merge pull request #2070 from modelcontextprotocol/v2/ci/2069-drop-redundant-pr-checkout
ci: drop the redundant PR-head checkout from claude.yml
2026-08-22 10:27:02 -04:00
cliffhall
f8e97c99a1 ci: drop the redundant PR-head checkout from claude.yml
Code scanning alert 71 (actions/untrusted-checkout/high) has stayed open
against main at the `Checkout PR branch` step — the same step #1966 added
the `is_fork == 'false'` guard to. The rule is syntactic, so it cannot see
that guard, the job-level author_association check, or the repo's
`collaborators_only` fork-PR policy; no hardening around the step clears it
while the step exists.

The step turns out to be redundant anyway. claude-code-action fetches and
checks out the PR branch itself, and its own docs name a bare base-ref
checkout as the preferred pattern — which is what upstream's own claude.yml
does. Our lookup-then-checkout pair is vestigial from the v1 example
workflow inherited in #1869.

So: delete the PR-head checkout, make the base checkout unconditional, and
drop the now-unused `sha` output. The `is_fork` gate on Run Claude Code
stays, and is now the only thing keeping fork code out of the workspace —
the action fetches a cross-repository head via refs/pull/N/head on its own.

Closes #2069

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-20 19:34:41 -04:00
Cliff Hall
471b86535e
Merge pull request #2061 from modelcontextprotocol/v2/chore/2058-nanoid-advisory
chore(deps): bump nanoid to 3.3.18 to clear the high-severity advisory
2026-08-18 23:29:46 -04:00
cliffhall
180b397aec chore(deps): bump nanoid to 3.3.18 to clear the high-severity advisory
`npm audit --omit=dev` reported one high-severity finding in the
production tree, on both `main` and `v2/main`:

  nanoid <3.3.18 — GHSA-2v37-7h3g-55p8
  custom generators can loop indefinitely when size is zero

It arrives transitively, via vite -> postcss -> nanoid. `postcss`
depends on `nanoid@^3.3.11`, so the fix is an in-range lockfile bump in
all five installs — no package.json changed. `npm audit --omit=dev` is
now clean at the root and in each of the four client installs.

Applied as a targeted `npm update nanoid` per install rather than
`npm audit fix`. The latter reaches the same clean audit but re-resolves
the whole tree, and here dragged in an unrelated esbuild downgrade
(0.27.7 -> 0.27.2) across web/cli/tui — roughly 700 lines of lockfile
noise for a one-package security patch.

The issue also asked whether `vite` belongs in root `dependencies`,
since that placement is why this advisory appears under `--omit=dev` at
all. It does, and deliberately: `start-vite-dev-server.ts` imports
`vite` and `@vitejs/plugin-react` at runtime for `--web --dev`, and
`tsup.runner.config.ts` lists both as `external`, so a published install
resolves them from the root manifest. Moving them to `devDependencies`
would break `--web --dev` for consumers while passing every local check.
Left in place, with the reasoning recorded in the README's
dependency-placement paragraph so it need not be re-derived.

Verified: `npm audit --omit=dev` reports 0 vulnerabilities in all five
installs; `npm run ci` passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-18 23:12:10 -04:00
Cliff Hall
7dc363ee10
Merge pull request #2059 from modelcontextprotocol/v2/chore/2057-sync-security-deps
chore(deps): sync the security fixes that landed on main
2026-08-18 22:58:22 -04:00
cliffhall
1edb4f78c7 chore(deps): sync the security fixes that landed on main
Dependabot security updates always target the default branch, so #1833,
#1840, #1913, #1925 and #2004 all landed on `main`. `main` is never merged
back into `v2/main`, so those fixes had no path to the develop branch and
every install off `v2/main` still resolved the vulnerable versions —
Dependabot will not re-raise them, since it considers them fixed on the
branch it watches.

Brings v2/main to parity: hono 4.12.25 -> 4.13.1, undici 8.5.0 -> 8.9.0,
body-parser 2.2.2 -> 2.3.0, ip-address 10.1.0 -> 10.5.0, fast-uri
3.1.0 -> 3.1.5, plus qs, type-is, express-rate-limit, hasown,
side-channel and es-object-atoms, across the root, web, cli and tui
installs. Two direct declarations move with them: cli undici ^8.9.0 and
web hono ^4.13.1.

The files are taken from the reconciled v2.3.0 milestone-merge tree, so
the root version stays 2.3.0 rather than regressing to main's 2.2.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-18 22:20:10 -04:00
Cliff Hall
e37e823116
Merge pull request #2052 from modelcontextprotocol/v2/chore/2013-bump-2-3-0
chore: bump version to 2.3.0
2026-08-18 19:58:45 -04:00
cliffhall
7ea75e94f1 chore: bump version to 2.3.0
First step of the v2.3.0 release, following the procedure established in
#2010 / #2011: the bump lands on v2/main before the milestone merge, so it
flows into main with the rest of the milestone's work rather than being
stranded downstream.

Applied with `npm version minor --no-git-tag-version` — no tag is created
here, since the release tag belongs on the main merge commit (step 3).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-18 19:37:15 -04:00
Cliff Hall
b020709d51
Merge pull request #2050 from modelcontextprotocol/v2/fix/2049-unhandled-list-reload-rejections
fix: handle background command rejections in App (#2049)
2026-08-18 12:53:12 -04:00
cliffhall
9f42a5ef10 test: narrow the fake-client cast to a single assertion
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-18 10:52:24 -04:00
cliffhall
f96c4583ee fix: handle background command rejections in App (#2049)
`runWithCommandAuthRecovery` rethrows anything that isn't an
`AuthRecoveryRequiredError`, and 17 of its 18 call sites were bare
`void runWithCommandAuthRecovery(...)` — so any non-auth failure of a
user-initiated list reload (Retry, Load next page, the pagination-mode
toggle) became an unhandled rejection in the browser.

Neither gate that should catch it could: `void` is the sanctioned
suppression for `no-floating-promises`, and `smoke:web:browser` — which
hard-fails on exactly this signal — never connects to a server.

Add `runCommandInBackground`, a fire-and-forget wrapper that owns the
rejection, and route every background command through it, so a new call
site cannot reintroduce the gap by omission. An optional `errorTitle`
picks the reporting: omitted where the list panel already renders the
failure with a Retry (a toast would duplicate it), passed where nothing
else records it — `logging/setLevel` and the tasks refresh.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-18 10:47:33 -04:00
Cliff Hall
7be5875dc5
Merge pull request #2047 from modelcontextprotocol/v2/fix/1998-paginated-list-errors
fix: surface list-fetch failures in paginated mode (#1998)
2026-08-18 08:20:56 -04:00
cliffhall
de60983ccc fix: surface list-fetch failures in paginated mode (#1998)
#1954 wired the list-load error off the aggregate managed stores only.
With `paginatedLists` on, those stores deliberately skip their all-page
walk, so their error is permanently null — while the paged stores that
actually drive the sidebar had no error state at all and `loadPage` had
a try/finally with no catch. A failing list showed an empty panel with
no alert and no Retry, and the connect-time `void loadPage(undefined)`
left an unhandled rejection.

- Give PagedTools/Prompts/ResourcesState observable error state: an
  `errorChange` event plus `getError()`, set in a new catch in
  `loadPage`, cleared on the next success and on disconnect. The
  rejection is still re-thrown, matching ManagedListState — callers'
  auth-recovery wrappers key off it to detect a 401.
- Catch the connect-time load's rejection (`.catch(() => {})`) now that
  the failure is recorded as state rather than lost.
- Select the error by mode in `usePaginatedList`, the same way `items`
  already is, and read it in App.tsx from the pagination model.
- Rename `useManagedListError` to `useListError`: both store families
  expose an identical `errorChange`, so the hook declares that one-event
  contract itself instead of importing ManagedListEventMap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 20:40:23 -04:00
Cliff Hall
885228699e
Merge pull request #2045 from modelcontextprotocol/v2/fix/1999-sse-onabort-before-priming
fix: register SSE abort cleanup before the priming write
2026-08-17 19:06:01 -04:00
cliffhall
e58c80440b fix: guard the abort listener against a double invocation
Copilot review round 1. The helper's contract is exactly-once teardown
and the test asserted it must not lean on Hono's own re-entry guard, but
neither the implementation nor the test actually enforced that: the
listener had no local guard and the test aborted only once.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 14:41:24 -04:00
Cliff Hall
86245a4a83
Merge pull request #2044 from modelcontextprotocol/v2/chore/2043-eslint-ignore-web-build
chore: ignore the web client's tsup build output in ESLint
2026-08-17 14:35:04 -04:00
cliffhall
3dfac1c3db fix: register SSE abort cleanup before the priming write
Both SSE handlers installed their subscriber, primed the stream so
Firefox's fetch() resolves (#1858), and only then registered onAbort.
Hono's onAbort has no already-aborted replay and abort() fires only the
subscribers registered at that moment, so a client disconnecting while
the priming write was in flight fired nothing: the session event
consumer / server-list subscriber stayed installed, the mcp.json watcher
was never stopped, and the handler parked forever on a promise nothing
would resolve.

Add primeAndHoldSseStream, which registers one listener before the first
await — it both cleans up and releases the hold, collapsing the previous
duplicate onAbort registrations. Priming still happens after the
subscriber is installed, since callers treat the stream's first bytes as
proof they are subscribed.

Closes #1999

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 14:30:58 -04:00
cliffhall
f8cf39ca4c docs: say two coverage guards, not three
verify:dep-lockstep is a durable guard but not a coverage guard — the two
coverage guards are verify:format-coverage and verify:typecheck-coverage,
which is the terminology the rest of the repo uses. The sentence said three
and then named two.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 14:18:05 -04:00
cliffhall
4fd7f39acf docs: state that build output is never a lint/format/typecheck target
Adds an AGENTS.md rule under the pre-push gate naming the gated surface
(first-party source) and everything excluded from it (each client's build/,
web's dist/ and storybook-static/, coverage/, test-servers/build,
core/**/{build,dist}, *.tsbuildinfo), why gating generated output is harmful
even when it only warns, and the fact that the three coverage guards assert
the converse and so cannot catch this drift. Mirrored into
.github/copilot-instructions.md per the maintenance rules.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 10:14:29 -04:00
cliffhall
6b27e5f932 chore: ignore the web client's tsup build output in ESLint
clients/web emits two bundles — dist (the Vite SPA) and build (the tsup
prod-server runner) — but only dist was in globalIgnores, so `npm run lint`
parsed ~1.2MB of generated output and reported an unused-eslint-disable
warning from inside the vendored undici source. Its three sibling clients
already ignore their build/ output.

Closes #2043

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 09:45:57 -04:00
Cliff Hall
5dd038eb21
Merge pull request #2041 from modelcontextprotocol/v2/fix/2001-duplicate-tool-selection
fix(web): key tool selection by row identity so duplicate tool names are individually inspectable
2026-08-17 08:19:42 -04:00
Cliff Hall
f8d8c77783
Merge pull request #2035 from modelcontextprotocol/v2/fix/1919-rfc6570-uri-template-expansion
fix(web): expand resource templates per RFC 6570
2026-08-17 08:17:54 -04:00
cliffhall
7bcf155e06 fix: scope the value ceiling to the names a template references
Addresses Copilot's round-19 review on #2035 -- a divergence introduced by
my own round-14 guard rather than by the original code.

The SDK validated a value inside `encodeValue`, i.e. only after looking one
up by a DECLARED name. The restored guard checked the caller's whole map, so
an unrelated entry failed a template that never mentions it. Measured on
9caac43d:

  tryExpandUriTemplate("x://{id}", { id: "7", stale: "a".repeat(1_000_001) })
    =>  error: The value for "stale" exceeds the 1000000-character limit

RFC 6570 ignores an extra variable, so this refused a perfectly valid read.
`valueLengthError` now takes the names to consider and both callers pass the
template's declared set.

Copilot's second half is worth taking on its own: strict expansion parsed
the template THREE times -- inside `templateError`, again for the name list,
again to expand -- and the template is server-controlled and may be 1 MB.
It is now parsed once and reused, with validation split into
`sdkTemplateError` (the constructor's unclosed-brace and length/count
limits) and `partsError` (this module's own grammar), so `templateError` and
strict expansion share both halves rather than duplicating either.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 01:54:37 -04:00
cliffhall
9caac43da4 fix: accept only well-formed UTF-8 sequences, and validate before previewing
Addresses Copilot's round-18 review on #2035. Two suppressed comments, both
holes in my own recent fixes rather than in the original code.

1. Round 16's grouping keyed off the length-announcing lead byte alone, so
   it accepted sequences RFC 3629 forbids -- and the comment claiming they
   fell back to per-triplet counting was simply wrong. Measured on 05bbfe50:

     x{+v:1}  "%C0%80x"        =>  "x%C0%80"        overlong
              "%E0%80%80x"     =>  "x%E0%80%80"     overlong
              "%ED%A0%80x"     =>  "x%ED%A0%80"     UTF-16 surrogate
              "%F4%90%80%80x"  =>  "x%F4%90%80%80"  past U+10FFFF

   The lead-byte ranges are now the well-formed ones -- C2-DF, E0 only with
   A0-BF, ED only with 80-9F, F0 only with 90-BF, F4 only with 80-8F -- so
   each of the above falls back to one triplet, which is what the fallback
   was always documented to do. The legal boundary cases (%E0%A0%80,
   %ED%9F%BF, %F4%8F%BF%BF) are tested alongside, so tightening the ranges
   cannot quietly start splitting valid characters.

2. The preview never ran the read's template validation. It expands
   expression by expression, so `x://{a}}/{b}` with both fields filled
   previewed `x://1}/2` -- a URI whose stray `}` makes every read refuse.

   Validation moved into an exported `templateError` that both callers
   consult, so they cannot drift. It subsumes the narrower rule round 12
   added here ("leave the invalid part standing"), which still let the REST
   of such a template expand: the refusal is per template, not per
   expression, so the preview now shows the template verbatim -- literals
   unencoded included, since half-normalizing an unreadable template invents
   a string the server never published. Two existing tests asserted the
   weaker behavior and now assert this one.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 01:40:50 -04:00
cliffhall
05bbfe5074 fix: read the completions map by own property, not through the prototype
Addresses Copilot's round-17 review on #2035. A real crash, and a class the
earlier prototype work missed rather than a narrower version of it.

`toString`, `constructor` and `__proto__` are valid RFC 6570 variable names,
and `completions` starts as `{}`. So for such a name:

  completions["toString"] ?? []   =>  the prototype's FUNCTION

`??` catches only null and undefined, so that function went to Mantine's
Autocomplete as its `data`, and the field crashed on first render with
`TypeError: data.map is not a function` -- reproduced by reverting the fix
under the new test.

The earlier rounds fixed this for the *values* map, which is seeded with
every declared variable and so always has the key as an own property. The
completions map is the one name-keyed map this component reads WITHOUT
having seeded it, which is what made it the surviving instance. Both reads
now go through `Object.hasOwn` -- the render, and the stale-dropdown check
that clears a variable's options on the next keystroke.

Only the autocomplete branch could reach it, so the regression drives that
branch: completions supported, a `{toString}` template, type and submit.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 01:25:58 -04:00
cliffhall
7d9410cbe0 fix: keep a pct-encoded UTF-8 sequence whole, and bound the preview too
Addresses Copilot's round-16 review on #2035. Two suppressed comments, both
real, and both refinements of fixes from the previous two rounds rather than
defects in the original code.

1. Round 11 settled on "a pct-encoded triplet counts as one character", but
   a multi-octet character spans several triplets -- `%C3%A9` is one `é`.
   Measured on a3dd8563:

     x{+v:1}  v = "%C3%A9x"  =>  "x%C3"   a lone lead byte, decoding to nothing

   `prefixUnits` now recognizes a well-formed pct-encoded UTF-8 sequence from
   its lead byte (RFC 3629: C2-DF two octets, E0-EF three, F0-F4 four;
   continuations 80-BF), longest alternative first so a four-octet sequence
   is not read as a two-octet one followed by loose triplets.

   A malformed sequence -- a lead byte with too few continuations, or a stray
   continuation -- matches no sequence alternative and falls back to
   per-triplet counting. That is the conservative direction: it counts as
   MORE units, so truncation keeps less rather than emitting octets that
   were never in the value.

2. Round 14's per-value ceiling only covered the read. The panel calls
   `previewUriTemplate` on every keystroke, and that path expands expression
   by expression, so it reached the allocation-heavy encoders with the same
   oversized value -- submission refused while the UI froze on the paste
   anyway. Both paths now share one exported `valueLengthError`, checked
   before any encoding happens.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 01:13:17 -04:00
cliffhall
a3dd856380 test: pin the InspectorClient wiring, and correct a false claim about the SDK
Addresses Copilot's round-15 review on #2035.

1. `readResourceFromTemplate` had no regression distinguishing this module
   from the SDK expander it replaced: the existing test uses `report.txt`
   and the transport tests expand separately and call `readResource`, so all
   of them pass either way.

   Writing that test corrected something I had asserted twice in this PR.
   The first version used `foo/bar` -- and verified by reverting the wiring,
   it still passed. `encodeURIComponent` DOES encode `/`, so the SDK expands
   `foobar://events/{topic}` to `foobar://events/foo%2Fbar` exactly as this
   module does. #1919 was the WEB PANEL's own `String.replace`, not the SDK
   expander, which the TUI path was using correctly all along.

   The test now uses `a!b`. `!` is a sub-delim: RFC 6570 requires it
   encoded, `encodeURIComponent` leaves it bare, so the SDK returns
   `foobar://events/a!b` and the assertion fails. Re-verified by reverting
   the wiring -- 2 of the 10 tests fail (this one and the malformed-template
   refusal), where before the change none did.

2. The module summary said "shared by every client" a dozen lines above the
   paragraph explaining the CLI is not a consumer. Now says web and TUI.

Also records, on MAX_VALUE_LENGTH, the audit prompted by round 14: the
per-value ceiling was the ONLY guard lost with `UriTemplate.expand`. The
template-length (1e6), expression-count (1e4) and variable-name-length (1e6)
limits all live in the SDK's `parse()`, which runs from the constructor --
and strict expansion still constructs a `UriTemplate`, so they remain
enforced. The restored limit is the SDK's `MAX_VARIABLE_LENGTH`, read from
the pinned dist rather than assumed.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:59:30 -04:00
cliffhall
c6b17c5241 fix: refuse a stray brace, bound value length, and drop a quadratic scan
Addresses Copilot's round-14 review on #2035. Three suppressed comments,
all real; each measured on 1c595e96 before acting.

1. A brace surviving in a literal is an unmatched one, and not a legal
   literal character. The SDK's constructor rejects an unclosed `{` but
   reads a stray `}` as text, and this parser did the same:

     expandUriTemplateStrict("x://a}", {})  =>  "x://a}"

   -- a "URI" carrying a brace, with the panel enabling the read on it.
   Both halves are now refused the same way, so the withhold behavior
   covers the whole class rather than the half the SDK happened to catch.

2. Replacing `UriTemplate.expand` also removed its 1,000,000-character
   per-value ceiling. Nothing else bounded what reached the
   allocation-heavy encoders, so a pasted or programmatically supplied
   megabyte-plus value went straight through. Restored at the SDK's own
   limit -- the guard it was, not a new policy -- and the error names the
   offending variable.

3. `templateVariables` re-scanned an expression's whole varspec list for
   every one of its names to compute `conforming`, which is quadratic in a
   multi-name expression. Templates are server-controlled and the SDK
   admits one up to 1 MB, so a single expression can carry thousands of
   short comma-separated names, and this runs on the render thread.
   Iterating the varspecs supplies the name and its conformance in one
   pass.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:47:19 -04:00
cliffhall
1c595e96ee fix: dedupe a repeated name in a group, and name every unmet requirement
Addresses Copilot's round-13 review on #2035. Two suppressed comments, both
real, both in the required-group work from round 7 rather than in the recent
preview rework.

1. `{a,a}` is ONE requirement named twice, but `requiredGroups` stored it as
   a two-name group, so everything downstream read it as "either of these
   will do". The TUI was left with a form it could not submit: its
   `length === 1` test marked `a` optional while ResourceTestModal's guard
   still refused a blank. The web panel offered the useless hint
   "Any one of: a, a". Names are now deduplicated within the expression --
   expansion still emits both occurrences (`x://{a,a}` -> `x://1,1`), which
   RFC 6570 requires.

2. The per-field hint used `find`, so a variable in several shared groups
   advertised only the first. With `{a,b}{b,c}{a,c}`, filling `b` satisfies
   the first two and every visible hint then looks met while Read Resource
   stays disabled on the unmet `{a,c}` -- a requirement with nothing on
   screen pointing at it.

   Fields now hint every shared group they sit in. That alone is not enough,
   because no per-field hint can say WHICH group is still outstanding, so
   the form states it directly: "Still needed: a or c", built from the same
   `unmetRequiredGroups` the submit gate uses, so the message and the button
   cannot disagree. `hasRequiredValues` is no longer called here -- the
   panel derives both from the one list.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:33:52 -04:00
Cliff Hall
7bda38220d
Merge pull request #1997 from modelcontextprotocol/v2/fix/1939-typecheck-coverage-windows
fix: resolve node bins via package.json instead of spawning npx .cmd shims so verify/smoke scripts run on Windows
2026-08-17 00:31:09 -04:00
cliffhall
3b995f6658 docs(spec): describe tool selection by row key, not name
`v2_ux_interfaces.md` and `v2_storage.md` still prescribed `selectedName` /
`selectedToolName` and `tools.find(t => t.name === …)` — the exact shape that
made a duplicated tool name uninspectable. Point them at `toolRowKey` /
`findToolByRowKey` and note the split between UI identity (the row key,
carried by `onSelectTool`) and wire identity (the protocol name, carried by
`onCallTool`).

Follow-up to #2001 review feedback.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:23:26 -04:00
cliffhall
6a345ae77c fix: keep the URI preview from throwing or promising an unsendable URI
Addresses Copilot's round-12 review on #2035. Both halves are regressions
from my own round-10 rework, which replaced "expand a rewritten template
through the lenient expandUriTemplate" with a part-by-part assembly and so
lost two properties that implementation had for free. Reproduced on
c6fb6271:

  previewUriTemplate("x://{v}",  { v: "\ud800" })  =>  throws URIError
  previewUriTemplate("x://{a,}", { a: "1" })       =>  "x://1"

The first is the worse one: the preview runs during render, so an escaping
throw unmounts the whole panel rather than disabling its button -- and an
unpaired surrogate is real input, since `encodeURIComponent` raises URIError
on one and a paste can deliver it. The panel already reports that case
properly through `tryExpandUriTemplate`; the display path now degrades to
the raw template instead of taking the panel down with it.

The second promised a URI the user could never send: `{a,}` is an invalid
template whose submission is refused, but `a` survives into the
expression's varspecs, so filling it previewed `x://1`. An invalid
expression is now left standing as written, which is what the rest of the
panel already says about it.

Tests for both, plus one asserting the valid expressions around an invalid
one still preview.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:22:09 -04:00
cliffhall
63b37e05ec fix(web): drop a pre-#2001 selectedToolName on OAuth resume
A resume snapshot written before this change carries `selectedToolName`
where `ToolsUiState` now expects `selectedToolKey`. The blind cast in
`restoreTabUiFromSnapshot` carried it through as a stray field that would be
re-serialized on the next redirect. Route the Tools entry through a
`normalizeToolsUi` that drops it explicitly instead.

The name is deliberately not migrated to a row key: a snapshot lives for one
redirect (so this only arises when the app is upgraded mid-flight), and the
tools list is fetched after reconnect, long after restore — there is nothing
to resolve the name against. The selection is lost; search, form values, and
the run-as-task toggle restore intact.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:19:22 -04:00
cliffhall
c93552139c fix(scripts): keep the resolver local, complete the cmd.exe quoting
Copilot review round 3, all three real:

- `require.resolve.paths()` also appends Node's GLOBAL FOLDERS
  ($HOME/.node_modules, $HOME/.node_libraries, $PREFIX/lib/node) and any
  NODE_PATH entries, so round 2's lookup could satisfy a missing repo
  install from a GLOBALLY installed typescript/vite — measuring the
  programs with the wrong compiler instead of producing the actionable
  "run npm install" this helper promises. Filter to the node_modules
  directories on fromDir's own ancestor chain.
- The quoting predicate missed characters cmd.exe still parses, notably
  parentheses (`C:\Temp(1)`), and quoting cannot suppress `%VAR%` at all
  — expansion happens before quotes are processed. Extracted to
  lib/win-shell-args.mjs with a broad predicate and a hard error on `%`
  rather than a silently different path. `platform` is a parameter so the
  Windows behavior is exercised on the POSIX machines that run the suite.
- AGENTS.md claimed pack-and-verify spawns via `process.execPath`; it does
  not, and cannot — its children are `npm` and the installed `.bin` shim.
  State the exception and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:18:40 -04:00
cliffhall
c6fb627142 fix: count a pct-triplet as one character under every operator
Addresses Copilot's round-11 review on #2035, which corrects my own round-9
fix rather than the original code.

That fix grouped pct-triplets only under `+` and `#`, on the reasoning that
every other operator escapes a `%` anyway, so `%61` is three characters
rather than one. Wrong, in a way the narrower rule made visible one operator
over -- measured on 2e06b369:

  x/{v:1}  v = "%61%62"  =>  "x/%25"     truncated to a `%` that was never
                                          a character of the value

RFC 6570 2.4.1 counts characters and says a pct-encoded triplet counts as a
single one, without reference to the operator. Grouping per-operator also
made the same input mean two different things: `%61` indivisible under `+`,
three loose characters under `{v}`. What the operator decides is how a
retained unit is *encoded* afterwards -- a simple expansion still escapes
the kept triplet's `%`, so `%61` goes out as `%2561` -- not what counts as
one character.

`prefixUnits` no longer takes an operator, and the test that codified the
old split now asserts the uniform rule across simple, named, and reserved
expansion.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:03:10 -04:00
cliffhall
ab40fb621a fix(web): key tool selection by row identity, not name
#1961 keyed the sidebar's React children by source position, but everything
downstream still identified a tool by `tool.name`. With a `tools/list` that
repeats a name, both copies highlighted at once, the click guard made the
other copy a no-op, and `tools.find((t) => t.name === …)` always resolved to
the first — so a later copy rendered but could never be opened.

Thread the row key through selection instead:

- `utils/toolUtils.ts` now owns `toolRowKey` (moved out of ToolControls) and
  adds `findToolByRowKey`, which compares computed keys rather than parsing
  one so the two can't drift.
- `ToolControls`: `selectedName` → `selectedKey`; the highlight and the click
  guard compare keys, and `onSelectTool` emits the row key.
- `ToolsScreen`: `ToolsUiState.selectedToolName` → `selectedToolKey`, with
  both lookups going through `findToolByRowKey`. Renamed through
  `screenUiState.ts` and `App.tsx`'s `onToolsUiChange`.

`onCallTool` still sends `selectedTool.name` — the duplicated name is the
genuine wire identity; only the UI identity needed to be unique.

Also prefixes the excluded (SEP-2243) rows' keys: they render into the same
`Stack` as the list rows but index within their own list, so an excluded tool
sharing a name and index with a listed one collided in the key space.

Closes #2001

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:02:13 -04:00
cliffhall
b399bc64e1 fix(scripts): find the manifest without exports, quote shell args on Windows
Copilot review round 2 (suppressed comments), all three real:

- `require.resolve("<pkg>/package.json")` is governed by the package's
  `exports`, and Node keeps no special case for `./package.json` — so a
  package declaring an `exports` map without it threw
  ERR_PACKAGE_PATH_NOT_EXPORTED even though its bin was installed and
  spawnable, a false "not installed" pointing at `npm install` for a
  package already on disk. Walk `resolve.paths()` instead, which never
  consults `exports`. The docblock claiming the opposite is corrected.
- `shell: true` (needed at all on Windows to start the `.cmd` shims) makes
  Node hand `cmd.exe` one space-joined string, so an argument holding a
  path with a space splits in two. Every generated path in
  pack-and-verify lives under `tmpdir()`, which sits beneath the user
  profile. Quote them via `shellArgs()` at all four spawn sites, the
  command path included — the hazard predates this PR, so fix the class.
- Document the new module: `scripts/lib/resolve-node-bin.mjs` in AGENTS.md's
  scripts/ tree, its tests in the README's `test:scripts` row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:01:51 -04:00
cliffhall
2e06b369f4 fix: encode literals per RFC 6570 3.1, and label the varname tolerance
Addresses Copilot's round-10 review on #2035. One inline comment, one
suppressed; both real.

1. Literals were copied through verbatim, but RFC 6570 3.1 requires them
   pct-encoded on expansion -- the conformance case `café/{var}` expands to
   `caf%C3%A9/value`. Measured on ef3f5e74 it produced `café/value`, raw
   UTF-8 in the path; since this module replaced the SDK's expander for both
   clients, nothing else was going to encode it. (The pinned SDK does not
   either -- it returns `café/value` too -- so this is a gap we inherited
   rather than introduced.)

   `encodeLiteral` keeps reserved delimiters (a literal's `/`, `?` and `#`
   are structure) and existing pct-triplets, and encodes the rest. A BRACE
   passes through: it can only reach a literal from a malformed template,
   where an unclosed `{` becomes trailing text, and `%7B` would leave a
   string resembling neither the template nor anything the server can match.

   This is also what removed the preview's placeholder machinery. No token
   can survive a round trip now that literals are encoded, so the preview
   assembles part by part instead -- `encodeLiteral` for literal runs,
   `expandTemplateExpression` for filled expressions, `part.source` for the
   rest. Both halves still come from the shared expander, so the preview
   cannot promise a URI that submitting would not send, and the question of
   what token could never collide with a server-supplied template is gone.

2. On the varname set I took the structural point rather than the
   conclusion. `VARCHAR` is RFC-exact again; `-` and `~` moved into a
   separately named `TOLERATED_VARCHAR` tier, and a name needing it parses
   with `conforming: false`, surfaced on `TemplateVariable`.

   So the grammar in the code says what the RFC says and the tolerance is
   explicit and separately tested -- but the Inspector still expands
   `{default-graph-uri}`, because real servers publish hyphenated names and
   the SDK's matcher round-trips them. Refusing to read a resource that
   demonstrably works is a worse failure for a debugging tool than expanding
   a name the conformance suite would reject. A caller wanting RFC-exact
   behavior refuses on the flag rather than re-deriving the grammar.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 23:44:45 -04:00
cliffhall
4b143ce043 fix(scripts): validate the bin name and that its file exists
Copilot review on #1997:

- A string-form `bin` is npm's shorthand for ONE command, named after the
  package, so ignoring `binName` let a typo silently resolve the package's
  only executable while bypassing the documented "declares no such bin"
  failure. Match it against the manifest's unscoped name instead.
- A declared bin whose file is absent (partial install) was returned
  unchecked. `process.execPath <missing>` still spawns and exits 1 with
  empty stdout, which `rawProjectListing` records as "no diagnostic
  captured" — reproducing the bogus every-file-uncovered report this
  helper exists to eliminate. Throw here, where the remedy is actionable.

Also restores `rawProjectListing`'s docblock, which the new `tscEntry`
block had orphaned above itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 23:43:24 -04:00
Cliff Hall
e9a581d912
Merge pull request #2038 from modelcontextprotocol/v2/feat/1915-manual-server-headers
fix(web): correct the Custom Headers OAuth hint, name key/value rows for a11y (#2040)
2026-08-16 23:36:32 -04:00
Cliff Hall
2333a98fe7
Merge branch 'v2/main' into v2/fix/1939-typecheck-coverage-windows 2026-08-16 23:23:04 -04:00
Cliff Hall
4d7405bea0
Merge branch 'v2/main' into v2/feat/1915-manual-server-headers 2026-08-16 23:21:17 -04:00
Cliff Hall
1e0fd88956
Merge pull request #2034 from modelcontextprotocol/v2/fix/1860-mrtr-results-panel
fix(web): name an empty tool result instead of "No results yet" (#1860)
2026-08-16 23:20:29 -04:00
cliffhall
ef3f5e74d8 Merge branch 'v2/fix/1919-rfc6570-uri-template-expansion' of https://github.com/modelcontextprotocol/inspector into v2/fix/1919-rfc6570-uri-template-expansion 2026-08-16 23:18:23 -04:00
cliffhall
52abe8a165 fix: count a pct-triplet as one character when truncating a prefix
Addresses Copilot's round-9 review on #2035.

RFC 6570 2.4.1 counts the prefix in *characters* and is explicit that a
pct-encoded triplet counts as a single one. Truncation went by code point,
so it cut a triplet in half -- measured on 6acaff70:

  x{+v:5}  v = "%61%62%63%64%65%66"  =>  "x%61%256"

The orphaned "%6" is not a triplet, so encodeAllowReserved then (correctly)
escaped its `%`, and the value went out carrying a byte the caller never
asked for. Now "x%61%62%63%64%65".

Triplets are units only under `+` and `#`. Under every other operator the
encoder escapes a `%` anyway (`%61` -> `%2561`), so the three characters
really are three and grouping them would mis-count the prefix -- asserted
both ways. Astral code points remain single units under both, which is what
`Array.from` was there for.

Also corrects the comment on `readResourceFromTemplate`, which still said
the shared helper drops blank values. It has not since 6acaff70: `params`
passes through as given, so only an absent key omits its expression and a
direct API caller can request `?q=` deliberately. A caller whose values come
from a form drops its own blanks first.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 23:18:20 -04:00
Cliff Hall
63250a6c52
Merge branch 'v2/main' into v2/fix/1919-rfc6570-uri-template-expansion 2026-08-16 23:16:27 -04:00