Commit graph

2,710 commits

Author SHA1 Message Date
cliffhall
78a65f7c20 fix(auth): address Copilot review round 17
Three findings. The first is the fourth consecutive one in the refresh path,
so it is answered by making that path testable rather than by patching it
again.

**The refresh-after-persist wiring was covered by nothing.** It lived as two
inline `await …; refresh()` pairs in `App.tsx`, which is deliberately outside
the coverage `include` — so the hook's tests and the modals' tests could both
pass while the callback joining them was broken, leaving the security footer
stale after exactly the write that changed it. Three of the last four rounds
found defects in this path; none of them could have been caught by a test,
because there was nowhere for one to live.

Extracted to `utils/refreshingPersist`, which is inside the gate and has its
own tests: refresh runs after the persist resolves (not before — refreshing
first re-reads the descriptor the pending write is about to invalidate),
arguments pass through unchanged (the server-settings caller is
`(id, settings)`, and a transparent wrapper is the difference between saving
the entry and dropping it), and a failed persist refreshes nothing while
propagating the error.

**A tmpfs-style false promise, in the path fallback.** With neither `HOME`
nor `USERPROFILE` set — an ordinary service environment —
`defaultSecretFilePath` returned `.mcp-inspector/secrets.json`, a *relative*
path, while `FileSecretStoreOptions.filePath` and the `SecretStorageInfo.path`
the footer offers to copy both promise an absolute one. Resolved like the two
override branches above it.

**A warning that was usually false.** The session-store notice fired on every
`/api/servers` read whenever the store was in-memory — including for the
default empty catalog, where "plaintext values are left on disk" is not true
of anything, repeated on every list refresh. It is now emitted lazily, at most
once per server instance, and only when the loop actually preserves a
non-empty secrets set. A log line that is usually untrue is one people learn
to skip, which costs it the occasion it matters.

The remaining suppressed comment is the hand-off's check-to-delete race,
tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 20:51:23 -04:00
cliffhall
52087f7739 fix(auth): address Copilot review round 16
Three findings. The first is the third consecutive round in which my previous
fix introduced the next defect, all in the same ~15 lines of `useInitialConfig`.

**The request token did not subsume the unmount guard, and I said it did.**
Bumping the generation on effect teardown invalidates loads already *in
flight*; a `refresh()` called after unmount claims the next token, is
therefore current, and commits into a dead hook. That is a live path — the
callers fire `refresh()` from an async settings persist that can outlive
`App`. Mounted state is tracked separately again and `stale()` checks both.

The test for this was also wrong, in the way that matters: it called
`refresh()` after unmount but never settled the fetch, so it could not observe
the commit it existed to prevent. It settles the promise now.

**A tmpfs mount was reported as durable.** `docker run --tmpfs
/home/node/.mcp-inspector` puts a real entry in mountinfo, so a mount-table
lookup answered "durable", selected the file store, and published
`durable: true` for a file that disappears when the container stops — the
exact false promise the in-memory fallback exists to avoid, reached by
trusting the mount table over the filesystem type. `tmpfs`/`ramfs` are
excluded now, read from the field after the ` - ` separator rather than by
position, since the optional fields before it are variable in number.

**A malformed envelope was diagnosed as a changed passphrase.** A truncated
payload, a wrong-length IV or tag, or an out-of-range KDF parameter all threw
`SecretFileKeyMismatchError`, whose 503 tells the user to restore a passphrase
that cannot repair a structurally broken file. The read path now runs the same
`encryptedEnvelopeProblem` check the descriptor path already ran, and reserves
the key-mismatch error for an envelope that is well-formed and fails
authentication — which is the case where that advice is right. Two existing
tests asserted the old behavior and encoded the bug; both updated, and a new
one pins that a well-formed envelope with the wrong key still gets the
passphrase advice.

The remaining suppressed comment is the hand-off's check-to-delete race,
tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 20:31:03 -04:00
cliffhall
0ab542cbee fix(auth): order /api/config loads so an older response cannot win
Round 15, one finding — in the refresh path added one commit ago.

`refresh()` fires after each secret-bearing save, and nothing ordered the
responses. Two debounced settings saves landing close together issue two
refreshes; if the earlier request resolves last, it commits the descriptor
from *before* the write and the footer reverts to "Plaintext file" for a file
that is now encrypted. Reverting a security statement to a stale value is the
worst direction for this particular field to be wrong in, and it needed no
adverse network conditions — just two saves in quick succession.

Loads now claim a monotonic token and may commit only while they still hold
the current one. That subsumes the liveness ref the previous commit added:
bumping the token on effect teardown covers unmount *and* a `baseUrl` /
`authToken` change, and bumping it on every load covers the overlap.

The test drives it by holding the `fetch` promises and resolving them out of
order — newer first, then older — which fails against the previous
implementation and passes here.

The remaining suppressed comment is the hand-off's check-to-delete race,
which is the cross-process guarantee tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 20:14:23 -04:00
cliffhall
0c52172379 fix(auth): address Copilot review round 14
One inline and three of the four suppressed. Two of the four are cases where
an earlier fix of mine was applied to one branch and not its twin.

**The footer went stale after the write that changed it.** `useInitialConfig`
fetched once, and `secretStorage` is the one field it carries that describes
state *this app mutates*: the first save under a newly-set passphrase
re-encrypts a pre-existing plaintext file. The backend re-derives the
descriptor per request — deliberately, and there is a test for that — but the
client had no way to ask again, so the footer said "Plaintext file" for the
rest of the session about a file that no longer was. Exactly the misreport
this band exists to prevent, arrived at from the client side.

The hook now exposes `refresh()`, and both persist paths that can carry a
secret call it: server settings (OAuth client secret, stdio `env:` values)
and client settings (the enterprise IdP client secret). The refresh tracks
its own liveness through a ref, since unlike the mount fetch it has no effect
teardown to cancel against.

**The plaintext branch skipped the payload validation the encrypted branch
does.** Round 12 tightened the encrypted envelope check and left this one
behind, so `{ "encryption": "none", "secrets": [] }` — trivial to hand-write —
described a healthy plaintext store whose next save `readMap` refuses. Both
branches validate now.

**`secretStoreGetStrict`'s doc contradicted the code it was written for.** It
said falling back to `get` is right "for the in-memory and file stores —
neither can fail in a way that masquerades as absence". `FileSecretStore.get`
does exactly that masquerade, which is why round 9 gave that store its own
`getStrict`. The fallback stays (the seam is optional and doubles must
compile) but the comment no longer claims it is equivalent for a store that
can fail.

**The spec documented the performance bug rather than the fix.** It still said
`getMany` decrypts once per server, which was the shape round 13 replaced —
and describing the earlier bug as the design is worse than saying nothing.

The remaining suppressed comment is the hand-off's TOCTOU delete: the
cross-process guarantee, decided against for this PR and tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 19:56:41 -04:00
cliffhall
26d2f58784 fix(auth): address Copilot review round 13
No new inline comments this round, but two of the four suppressed ones were
real defects rather than restatements — both in code I added, both silently
wrong in the safe-looking direction.

**A symlinked secrets directory read as unmounted.** `isOnMountPoint` walks
to the nearest existing ancestor *lexically* and then string-prefixes it
against mountinfo, so `~/.mcp-inspector -> /data/inspector` with `/data`
mounted matched no mount point. The container then selected the session-only
store on a box that had explicitly arranged for persistence, and nothing said
so — the mount was there, the volume was there, the secrets evaporated on
exit. Resolved through symlinks now, tolerantly: a path that cannot be
resolved falls back to the lexical comparison, which is what this did before
and is correct whenever no symlink is involved. The `statSync` fallback branch
already followed links, so this also makes the two paths agree.

**The bulk read did not fix the case it was written for.** `getMany` batched
*one server's* fields, but both rehydration callers iterate servers — so a
catalog of 20 servers holding one secret each still paid 20 serialized scrypt
derivations. The same ~450ms stall the seam was introduced to remove, reached
one server at a time instead of one field at a time. Measuring the wrong unit
and then optimizing it is worse than not optimizing, because the number in the
commit message says it is fixed.

The seam is cross-server now — `getMany(requests: SecretBulkRequest[])` — and
both callers pass the whole catalog in one call. `FileSecretStore` decrypts
once for the entire rehydration; the keychain fallback still issues
independent parallel `get`s, which is what it wants. A test asserts one
derivation for four servers, next to the existing one asserting one for three
fields, so the property is pinned at both axes.

The other two suppressed comments are the cross-process guarantee — `mutate`'s
residual and the hand-off's TOCTOU delete — which remain decided against for
this PR and tracked in #2082.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 19:39:45 -04:00
cliffhall
bc4b774d80 fix(auth): address Copilot review round 12
Three inline findings and one of the two suppressed. The other suppressed
one — that the hand-off's delete is still a TOCTOU — is the cross-process
guarantee, decided against for this PR and tracked as #2082.

**My convergence tests did not converge anything.** The reviewer caught that
the "two stores racing" tests cannot race: `serialize` keys its queue on the
resolved path *process-wide*, which I made it do in the previous commit
precisely so two instances would take turns. So the tests passed by
serialization while their names and comments claimed they were exercising
the optimistic retry, and the retry's successful path had no coverage at all.
A fix I was pleased with silently disabled the tests written to prove it
worked.

Split in two now, both honest about what they drive:

- `in-process mutations are serialized per path` keeps the multi-instance
  cases, which pin a real property — it is what makes the in-process case
  correct *without* relying on the retry.
- `cross-process convergence` injects one external write between the store's
  write and its verifying read, via raw `fs` so it bypasses the queue the way
  another process would. The surviving entry can only be explained by the
  re-apply, so these pin the mechanism rather than the outcome. Covers a
  set, a delete, and the encrypted path where every attempt re-derives.

**A well-formed envelope is not an openable one.** `readOnDiskEncryption`
classified by structure alone, so a wrong or unset `MCP_INSPECTOR_SECRET_KEY`
produced the quiet, healthy "Encrypted file" footer for a file whose every
`get` returns `null` and every `set` is refused — the exact state the
unreadable footer promises to warn about. It now authenticates: decrypt and
discard, costing one scrypt derivation (~23ms) per descriptor build, once per
`/api/config`. A test asserted the old behavior and has been rewritten; the
comment on it explains why "describable" has to mean *accurately* describable.

**The copy button's `aria-label` was eating the warning.** `aria-label`
replaces descendant text, so a screen-reader user heard "Copy secrets file
path …" and never heard "Plaintext file" — the one sentence that should
change their mind about typing a secret. The status now leads the accessible
name and the path follows, which also keeps the name a superset of the
visible text (WCAG 2.5.3).

**The spec still described the pre-#1950 invariant** in its migration
bullets: every plaintext secret lifted into the keychain and removed from
`mcp.json`, with only `KeychainUnavailableError` named. Both bullets now
describe the selected store, the durability gate that withholds the disk
delete for a session-scoped store, the strict read behind the store-wins
lookup, and `SecretStoreUnavailableError` as the family the handler catches.

Full `npm run ci` green, including the per-file ≥90 gate — the new validator
and retry branches needed six more cases to clear it.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 19:09:16 -04:00
cliffhall
219765efff fix(auth): address Copilot review round 10, and document the concurrency design
The headline is a correction to my own claim rather than a new defect.

**The optimistic verify is weaker than the comment said it was.** It
described the residual as a crash between the write and the verifying read.
The reviewer pointed out that no crash is needed: order two writers as
write-A, verify-A, write-B, verify-B and both report success while A's entry
is gone — A's verify simply ran before there was anything to see, and B did
nothing wrong. A crash is one instance of that shape, not the whole of it.
The trace in the old comment quietly assumed A verifies *after* B writes,
which nothing guarantees.

Corrected in the doc comment, the README and the spec, and the honest
summary is now stated rather than implied: this converges in every
interleaving where the clobber lands before the verify, it is not mutual
exclusion, and a deployment that needs the guarantee wants an OS-backed lock
from a dedicated library — not another hand-rolled election. It remains
better than what it replaced, which lost updates across a wider set of
interleavings and could not be closed at all with what Node exposes.

The other findings:

- **An envelope naming the cipher still is not an openable one.** Round 9
  added a `kdf`/`data` presence check; three dot-separated strings satisfied
  it while an empty tag, a 4-byte IV or `N: 3` still made every decrypt
  throw — so the quiet "Encrypted file" footer went to a file whose next
  save was guaranteed to fail. Now validates part sizes, a non-empty
  ciphertext, and the scrypt cost parameters (node rejects a non-power-of-two
  `N` synchronously, out of the derivation rather than the decrypt). A
  positive control asserts a file we actually wrote still reads as encrypted.

- **`encryptionUnknown` + `looseMode` claimed an exposure it had not
  established.** With both set, `plaintext` is absent, so a `!== false` test
  landed on "anyone who can read it can read the secrets in it" — about a
  file that may well hold ciphertext we simply could not classify. Three-way
  now, in `secretStorageCaveat` and mirrored in the footer's tooltip, with a
  test on each.

- **The README described a lock that no longer exists**, which is the worst
  kind of stale doc: it stated a durability guarantee the code had stopped
  providing. Rewritten to describe the actual design, including the residual.

- **The private-method access in the concurrency test** now carries the
  justification the repo's TypeScript rules require: the asserted shape is
  `writeMap`'s real signature, so a rename breaks the line instead of
  silently detaching the stub, and a production injection seam would let the
  real write path drift from the tested one.

Also updates `specification/v2_servers_file.md`, which still described #1356's
world: one keychain-only store, and unavailability as a hard error. It now
covers store selection and the probe, the file format and lazy encryption
upgrade, the concurrency design and its residual, the `getStrict`/`getMany`
seams and the two traps `getStrict` hit, the durability gate on migration,
the keychain hand-off and its conditional delete, and how the active store is
surfaced.

Full `npm run ci` green. (One unrelated `AppRenderer` theme-flip test failed
once under full-suite parallelism and passes in isolation with and without
these changes — a flake, not a regression.)

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 18:33:59 -04:00
cliffhall
339aea3729 fix(auth): replace the secrets-file lock with optimistic verify-and-retry, and address review round 9
Two changes, interleaved in the same files because both are about the
same read-modify-write path.

## The lock is gone (maintainer decision on the round-5/7/8 finding)

Three consecutive review rounds found a real race in the `mkdir` lock, and
the last one is not patchable with what Node exposes: claiming a stale lock
needs compare-and-swap on a directory entry (`renameat2(RENAME_EXCHANGE)`).
Without it, `canSteal` inspects one lock but `rename` acts on whatever is at
that path *later*, so a waiter that lost the first race can move the
winner's fresh lock aside and enter alongside it.

Rather than patch it a fourth time, writers now collide and the loser
notices. `mutate` reads `M0`, applies the mutation to get `M1`, writes it,
then reads back `M2`; if they differ, someone wrote in between, so it
re-applies onto what they left and retries, bounded.

The comparison is over the **whole map**, and that is the load-bearing
detail. Checking only the entry we touched passes in exactly the case that
loses data — ours is present, and it is the *other* writer's that is gone.
With A and B from the same `M0`: A writes `MA`, B writes `MB` and clobbers
A; B verifies and correctly sees `MB`; A verifies, sees `M2 !== MA`,
re-applies onto `MB`, writes again. Both survive, and the writer that was
clobbered is the one that repairs it.

Two things this deliberately does not claim:

- It is **not mutual exclusion**. A process that crashes between its write
  and its verifying read cannot notice it was clobbered, and nothing else
  will. That window is strictly narrower than the lock's — which lost
  updates with every participant alive — and needs no primitive Node lacks.
  Stated in the code rather than left to be rediscovered in review.
- Non-convergence **fails loudly**. After five lost rounds `set` throws
  rather than returning, because returning would reintroduce the silent
  loss with extra steps. `delete` stays silent, per the interface contract.

`serialize` is now keyed on the resolved path **process-wide** rather than
per instance. Two `FileSecretStore`s on one file are ordinary — the resolved
store holds one and `absorbFileSecretsIntoKeyring` builds another — and a
per-instance queue does not order them against each other at all. The new
convergence test caught this on its first run: two instances racing `set`
lost an entry. In-process is now correct by construction; the verify covers
what it cannot see, a second Inspector process.

The hand-off's read → copy → delete was the lock's other user. Its delete is
now conditional: the file is re-read immediately before removal and compared
against what was copied, and it declines to delete when they differ. Leaving
the file costs nothing (keychain-wins makes the next run idempotent) while
deleting on a stale read costs a secret.

## Round 9 (two inline, two suppressed)

- **`KeyringSecretStore.getStrict` let raw binding errors escape.** Both
  plaintext migrations keep their source file only when they catch
  `SecretStoreUnavailableError`, so a transient keychain failure 500'd
  `GET /api/servers` instead of abandoning the migration. Wrapped, as `set`
  already does.
- **`FileSecretStore` had no strict read at all**, so `secretStoreGetStrict`
  fell back to the tolerant `get` and mapped every read failure to `null` —
  leaving the exact inversion the seam was introduced to prevent open on the
  store it was introduced for.
- **`readOnDiskEncryption` reported "encrypted" for an envelope it cannot
  open.** Naming the cipher is not the same as being openable: a file with no
  `kdf`/`data`, or a `data` that is not `iv.tag.ciphertext`, produced the
  quiet reassuring footer for a file whose next save was guaranteed to fail.
- **`/api/config` kept a stale descriptor when the resolver returned
  `undefined`**, because the conditional spread left the startup value
  standing. Assigned unconditionally now; `c.json` omits the key.

Full `npm run ci` green.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 18:05:26 -04:00
cliffhall
37faf1be88 fix(auth): address Copilot review round 8 (four inline findings)
The lock finding from this round is deliberately not addressed here — see
below.

- **My round-6 strict-read fix was inert in production.** `DeferredSecretStore`
  is what `defaultSecretStore()` returns, and it did not forward `getStrict`
  — so `secretStoreGetStrict` fell through to the tolerant `get` for every
  real caller, and the strictness existed only in tests that injected a
  concrete store. Forwarded now, along with `getMany`.

- **The same tolerant-read bug remained in both pre-existing migrations.**
  I added `getStrict` for the new hand-off in round 6 and never applied it to
  the mcp.json and client.json migrations, which have the identical
  shape: decide from `get`, then write on `null`. A transient keychain read
  failure therefore let an older plaintext value overwrite a newer keychain
  one — and the disk copy was then stripped, so the newer value was gone. Both
  use the strict read now; the throw is a `SecretStoreUnavailableError`, which
  both call sites already handle as "abandon the migration, keep the file".

- **The permission check made a false statement on Windows.** `stat` returns
  synthetic bits there, and interpreting them yields a confident falsehood
  either way — "0666 — not owner-only" about a file the ACL may restrict, or
  "owner-only" about one it does not. The ACL is the real answer and this does
  not inspect it, so Windows now reports `unknown`.

- **Rehydration cost one full decryption per field.** `get` reads and decrypts
  the entire file, and both rehydration paths ask field by field, serialized
  behind the store's own queue. Measured on this machine, scrypt at N=16384 is
  ~23ms per derivation, so a 20-secret encrypted catalog spent **~450ms of
  pure key derivation on every `GET /api/servers`** — a visible stall, not a
  micro-optimization. Added an optional `getMany` seam: `FileSecretStore`
  decrypts once per server, everything else falls back to the parallel
  per-field `get` that already suited it. A test counts derivations directly:
  1 for a three-field `getMany`, 3 for the three `get`s it replaced.

Also broadens a log message that still said "Keychain unavailable" for a
store that may not be a keychain.

Full `npm run ci` green — 5,762 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 17:41:56 -04:00
cliffhall
5f4a6ea09b test(auth): pin the loose-mode test to the mechanism that survived
No behavior change.

Round 7 removed the loose-mode `console.warn` from selection — it was
encryption-blind and duplicated the caveat printed moments later. This test
kept asserting `warn` was called with "0644" and kept passing, because the
caveat also contains the mode. So it was green while describing a code path
that no longer exists: named "warns once at selection", commented as if the
selection warning were the thing under test, and pinned to a substring loose
enough that either line would satisfy it.

Asserts the caveat's full sentence now, so it is fastened to the surviving
mechanism rather than to whichever line happens to mention the mode.

Also records what it took to reach this state at all, since it is not
obvious and I had it wrong: as the file's owner you *cannot* stage a
loose-mode descriptor, because `tightenSecretFilePermissions` repairs a 0644
file to 0600 during selection and the report is then correctly "owner-only".
It is only real for a file owned by another user or on a read-only mount,
which is why the chmod is mocked — standing in for a privilege a test cannot
drop.

Found by @mcp-inspector-v2-f1, who tried to screenshot the state for the PR
body and got "Owner-only permissions." instead.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 17:12:29 -04:00
cliffhall
6efc65d2af fix(auth): address Copilot review round 7 (five of six findings)
Five taken; one rejected with evidence, below.

- **The keychain hand-off ran outside the file lock.** Read → copy → delete
  has to be one transaction: another Inspector completing a `set` between
  the read and the delete had its brand-new secret removed without ever
  being copied — a write that reported success and then vanished, which is
  the loss the lock exists to prevent, reached through the migration meant
  to preserve things. The whole sequence now runs under the same
  cross-process lock every mutation takes.

- **An unmigratable entry was skipped and the file deleted anyway.** A key
  that is not `serverId:field` cannot be addressed through the store API,
  so it cannot be copied — which makes the hand-off incomplete by
  definition, and the complete-success rule then forbids removing the
  source. It was discarding that value permanently in order to tidy up a
  migration that had failed. The copyable entries still go across; the file
  stays, and says why.

- **`readOnDiskEncryption` accepted any format version.** A version-2 file
  was reported as a usable plaintext or encrypted store even though
  `readMap` refuses it and every save fails. Checked before the mode now,
  matching `readMap`.

- **The loose-mode warning at selection was encryption-blind**, claiming a
  reader could read the secrets of a file that is ciphertext, and it
  duplicated the caveat printed moments later by `warnAboutSecretStorage`.
  Selection now only repairs; the descriptor path does the reporting,
  because it is the one that knows whether the file is encrypted.

- **The footer's tooltip offered to copy the path without showing it.** The
  band stopped printing it and the accessible name carries it for
  screen-reader users, which left a sighted user unable to see where a
  custom destination points without pasting it somewhere else.

**Not taken: "`writeStoreFile` is not atomic."** It is. The finding reads
`store-io.ts` line 84 as a direct write, but `writeFile` there is imported
from the `atomically` package, not `node:fs`. Verified rather than argued:
writing over an existing file changes its inode (so a rename happened) and
leaves mode `0600`. I had started implementing the suggested temp+rename
before checking the import, which would have replaced a tested library with
a hand-rolled duplicate.

That check corrected something of mine, though: `tightenSecretFilePermissions`
justified itself with "a rename over a 0644 file keeps the loose mode
forever", which is false — the temp carries `mode: 0o600` and the rename
replaces the inode, so every *write* re-establishes it. The function is
still needed for the case it actually addresses: a file that exists and is
never written. Comment corrected to say so.

Full `npm run ci` green — 5,753 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 14:57:55 -04:00
cliffhall
fdeffa1f00 fix(auth): address Copilot review round 6 on the secret-store PR
Four findings, all suppressed, all real. Three are the same mistake in
different places: a state that was never established being reported as a
confident answer.

- **`readOnDiskEncryption` conflated "no file" with "unreadable file".**
  Both answered `null`, and the descriptor falls back to the configured
  write policy on `null` — so a passphrase-configured install reported
  "File (encrypted)" about a file whose envelope had never parsed, and
  which `set` was in fact about to refuse rather than overwrite. It now
  returns a four-state result, and only `absent` falls back to the policy
  (honestly: the first write really will create it that way). `unreadable`
  produces `encryptionUnknown` on the descriptor, `plaintext` is omitted
  entirely so nothing can read a default out of it, and the label, caveat,
  tone and footer all carry "unreadable" rather than guessing a mode.

- **The loose-mode warning overstated exposure for an encrypted file.**
  "Anyone who can read it can read the secrets in it" is false when the
  file is ciphertext — a reader gets the bytes and still needs the
  passphrase. Both the caveat and the footer tooltip now branch on
  encryption: the passphrase becomes the only protection, which is the
  real consequence. Overstating it is not harmless, because a warning that
  cries wolf is discounted on the occasion it is literally true.

- **The keychain hand-off could invert its own keychain-wins rule.**
  `SecretStore.get` answers `null` both for a missing entry and for a
  keychain it could not read, and the migration *writes* on `null` — so a
  transient read failure would overwrite a newer keychain value with the
  older file copy. Added an optional `getStrict` (throws instead of
  swallowing) implemented by `KeyringSecretStore`, with
  `secretStoreGetStrict` falling back to `get` for stores whose reads
  cannot fail. A throw now aborts the hand-off with the file left in
  place, so the next run retries.

Writing the tests turned up one more, in code the review had passed:
`parseOwner` recorded a garbled pid as `-1`, and `process.kill(-1, 0)` is
a *wildcard* asking about every process this user can signal — it answers
"alive" almost always, which would have made a lock with a corrupt stamp
permanently unstealable. Only a positive pid is usable now; anything else
is treated as no pid at all and the mtime decides.

Tests: the four envelope states kept distinct; the descriptor refusing to
guess, with the genuine no-file case as the control; strict-read abort
without a write and with the file preserved, plus a store whose `get` and
`getStrict` disagree so the test proves which one the migration consults;
encryption-aware wording asserted in both directions in the caveat and the
band; three unusable owner stamps and a wildcard pid; and the descriptor
shapes that must now be rejected — neither half of the encryption question
answered, or both. Full `npm run ci` green — 5,750 tests under the
coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 14:40:29 -04:00
cliffhall
1aa1f5a0ca docs(web): attach the footer doc blocks to the functions they describe
No behavior change.

Adding `filePermissionsSentence` in decc673a inserted it directly under
`footerMessage`'s doc block, which left two block comments stacked and each
attached to the wrong function: `footerMessage` was undocumented, and its
"why not `secretStorageLabel`" rationale read as though it were about the
permissions helper, which it is not.

Moves that block back above `footerMessage`, and drops the inline comment
inside it that now duplicated the new helper's doc — and had gone stale
besides, since it still described two permission states rather than three.

Caught by @mcp-inspector-v2-f1 on a read of the pushed commit.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 14:24:09 -04:00
cliffhall
decc673a5d fix(auth): address Copilot review round 5, and land the impl its tests need
Repairs origin as well as answering the review. 2d2c787e committed the
`absorbFileSecretsIntoKeyring` tests without the implementation they
import, so the branch has been failing `tsc` since; this commit brings the
source across.

Round 5's four findings (two inline, two suppressed):

- **The stale-lock takeover was still racy.** Round 4 made *stealing*
  liveness-aware but left the takeover as remove-then-recreate, which is
  not an election: two waiters can both judge the same lock stale, the
  first removes it and takes a fresh one, and the second then removes
  *that* — both inside the section, which is the lost update the takeover
  exists to prevent. Claiming is now an atomic `rename` to a
  token-unique name, so exactly one waiter can take a given stale lock and
  the losers get ENOENT. A failed claim also falls through to the deadline
  instead of `continue`ing, so a takeover that can never succeed times out
  rather than spinning forever on a swallowed error.

- **Every `stat` failure was read as "no file yet".** An existing file we
  cannot inspect (EACCES on the directory, a mode-less filesystem) then
  produced no warning at all, and the footer went on stating owner-only
  permissions having verified nothing. There is now a third state,
  `unknown`, carried end to end: `permissionsUnknown` on the descriptor, a
  caveat that says the permissions could not be checked, a `warn` tone, and
  its own branch in the footer sentence — which otherwise would have
  claimed "Owner-only permissions." by omission, the same bug one layer up.

- **mountinfo decoded only `\040`.** The kernel escapes tab, newline and
  backslash too (`\011`, `\012`, `\134`), so a mounted path containing any
  of them was compared in its escaped form, matched nothing, and read as
  the container's writable layer — `memory` selected and persistence
  silently lost on a box that had arranged for it. Decoded as octal in
  general now, since the rule is octal-in-general and a future addition
  would otherwise reintroduce exactly this.

- **File-backed secrets vanished once a keychain appeared.** Save secrets
  without libsecret, install libsecret, and the next run selects the
  keychain and stops seeing them: still on disk, read by nothing, and
  nothing looks broken. `absorbFileSecretsIntoKeyring` hands them over on
  the run that finds a keychain, under the rules the two existing plaintext
  migrations already use — keychain wins on conflict, the file is removed
  only on complete success, and a file that cannot be read is never
  deleted, because that is precisely the case where the values would be
  lost.

Tests: a two-waiter race over one stale lock asserting a single holder
throughout; `unknown` from `readSecretFilePermissions` through the
descriptor to the footer sentence and tone; the hand-off's happy path,
keychain-wins, write-failure, undecryptable-file, empty-file,
malformed-key, and explicitly-configured-keyring cases; and six more
rejected descriptor shapes, including each file-only field on a non-file
kind. Full `npm run ci` green — 5,732 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 14:15:35 -04:00
cliffhall
71cb7f3eae feat(web): state the file store as a sentence, and copy its path on click
The footer's file states now read "Secrets: Plaintext file. Owner-only
permissions." and "Secrets: Encrypted file. Owner-only permissions.",
and the band itself became the copy affordance for the path.

Three things worth reviewing:

- The path is no longer printed on the band. It goes to the clipboard
  instead, which means the only place it stays *readable* is the
  button's accessible name — so that carries it in full
  ("Copy secrets file path: /home/node/.mcp-inspector/secrets.json")
  rather than a generic "Copy". A clipboard is no use to a screen-reader
  user who cannot inspect it.

- "Owner-only permissions." is a claim of fact, so it is made only when
  the mode was verified as 0600. `looseMode` is set exactly when it is
  something else and could not be tightened, and the message says
  "Mode 0644 — not owner-only." there. A footer that asserted the
  opposite of the truth about a secrets file is the one failure this
  band exists to prevent, so it has its own test.

- The footer phrases its own copy rather than reusing
  `secretStorageLabel` for the file case: that label is parenthetical
  ("File (unencrypted)") because the startup banner appends
  " at <path>" to it. The tone and the underlying facts still come from
  the shared helpers, so the two surfaces cannot disagree about
  anything that matters. Memory's line is still derived from the shared
  caveat.

The click target is an `UnstyledButton` filling the band edge to edge —
a real control, reachable by keyboard and announced as a button, rather
than a click handler on a div. Padding moved from the band to the inner
row so the button has no dead margins.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 14:05:13 -04:00
cliffhall
2d2c787e22 style(web): tighten the in-memory caveat and punctuate the footer
Reads "Memory (this session only): Secrets are not written anywhere and
are lost on exit." — shorter tail than "lost when the Inspector exits",
and a colon separating the store from what follows it.

The colon is added at the footer's composition, not inside
`secretStorageLabel`. The label is also consumed alone by
`secretStorageSummary`, which appends " at <path>" for a file-backed
store, so a colon baked into the label would print
"File (encrypted): at /path" in the startup banner. It renders only when
a detail actually follows.

The caveat string is shared with the banner's warning line, so the
terminal picks up the shorter wording too.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 13:56:13 -04:00
cliffhall
0cb59cf3fd fix(auth): stop heartbeating a lock that is no longer ours
Found on a re-read of the round-4 liveness work, not by review.

The heartbeat refreshed the lock directory's mtime unconditionally. After
a steal — possible when this process looked dead, or across hosts where
the pid check cannot apply — the directory belongs to someone else, and
touching it keeps *their* lock looking alive. If they then die, the stale
timestamp nobody is maintaining is being maintained by us, and a third
writer can never break in: a stale lock turned unbreakable, which is the
permanent outage the stealing exists to avoid.

The heartbeat now re-reads the owner stamp and clears itself when the
token no longer matches, so it only ever refreshes a lock we still hold.
The release path already checked the token; this closes the same hole on
the other side.

Tested by stamping a different owner onto a held lock and aging it: the
mtime stays old across several heartbeat intervals, and release leaves the
thief's lock intact.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 13:50:40 -04:00
cliffhall
050556e1a7 fix(auth): address Copilot review round 4 on the secret-store PR
Four findings (two inline, two suppressed), all taken. Three are the lock,
which is where the remaining sharp edges were.

- **A lock could be created and then never stamped.** If `mkdir` succeeded
  but writing the owner token failed, the catch treated it as "cannot lock
  here", returned the no-op release, and left the directory behind. The
  mutation then ran unlocked *and* every later writer queued behind an
  orphan nobody could deliberately release, until it aged out. The
  acquisition now tracks whether it created the directory, removes it on a
  failed stamp, and refuses rather than entering the section unlocked.

- **An old mtime was being treated as proof the owner was dead.** It is
  not: a holder that is SIGSTOPed, starved by a blocked event loop, or on a
  machine that suspended is alive and not running, and expiring it on
  elapsed time alone takes the lock out from under a live critical section
  — the lost update the lock exists to prevent, arrived at through the
  machinery meant to prevent it. The heartbeat narrowed the window; it
  could not close it, because the holder that most needs protecting is
  precisely the one not running its timers.

  The owner stamp now carries `pid` and `host`, and staleness only *opens*
  the question — on the same host a waiter asks the OS whether that pid
  still exists (`kill(pid, 0)`, which sends no signal; `EPERM` counts as
  alive, so a holder running as another user is never stolen from). Only a
  genuinely absent process is stolen from. Where the stamp cannot be
  trusted — another machine over a shared filesystem, or a missing/corrupt
  owner file — the mtime remains the answer, because the alternative is a
  lock nothing can ever break, turning one dead process into a permanent
  outage.

- **Podman was classified as a host.** `/.dockerenv` is Docker's marker and
  rootless Podman has no equivalent of it, while its cgroup paths say
  `libpod-<id>.scope` rather than the literal "podman" the pattern looked
  for. Such a box fell back to a file in the ephemeral writable layer and
  reported it as durable — the wrong way round to be wrong.
  `/run/.containerenv` and `libpod` are both recognized now.

- **Two tests floated a second `acquireLock`**, so the test could finish and
  `afterEach` could remove the temp directory while the acquisition was
  still running. Both hold and settle it now.

Separately, a defect of my own found while re-reading round 3:
`describeFileStore` called `tightenSecretFilePermissions`, so the function
whose job is to *describe* the store issued a `chmod` on every
`GET /api/config` — once per page load, on a file another process may be
mid-write on. Split out a read-only `readSecretFilePermissions`; the repair
stays at startup, where it happens once and its outcome is announced.

Tests: a stale lock whose owner is alive is not stolen (and survives the
attempt), one whose pid is gone is, one stamped by another host is; a
failed ownership stamp reports and leaves nothing behind; Podman via both
signals; and the read-only permission check leaves a 0644 file at 0644.
Full `npm run ci` green — 5,711 tests under the coverage gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 13:40:32 -04:00
cliffhall
6460d1804a style(web): bold the "Secrets:" prefix in the settings-modal footers
Weights the label so the band reads as a labelled fact rather than one
run of prose — the eye lands on what the line is about before reading
which store it names.

Rendered as a `span` inside the existing `Text` rather than as a sibling
in the `Group`: `Text` renders a `<p>`, so a nested one would be a `<p>`
inside a `<p>` (invalid, and the browser closes the outer paragraph
early), and a sibling would take the Group's `gap` and sit too far from
the value it labels. `inherit` keeps it at the label's own size.

Splitting the text is why the assertions move off `getByText`: it matches
a string against an element's *direct* text nodes, so "Secrets: OS
keychain" now reads as two fragments even though it renders as one line.
The unit tests, both modal tests, and the four stories assert
`toHaveTextContent` on the band instead, which reads the full subtree.

Verified in the running dev server: prefix 700, store name 500, both at
the same 12px.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-22 13:31:04 -04:00
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