chore: merge v2/main for the v2.4.0 milestone release - #2145
Merged
Conversation
Eight findings from the Copilot review, all accepted.
Ownership across connections was the substantive one, in three parts. A
mid-session transport close reaches teardown only through
`clearAndAnnouncePendingPeerRequests`, whose emptiness check looked at the
native queues alone — so a lone app elicitation survived a dropped connection
with its modal still open; the check now counts the active set too. The web
bridge factory resolves its client at call time, so an entry queued by a
replaced `InspectorClient` could rebuild against the *next* one and answer
through a different server; the host now drops every entry synchronously on
the swap (`AppElicitationController.failAll`) rather than racing core's
awaited teardown. And the request id was only monotonic within one client, so
each replacement client's first request was `app-elicitation-1` — settling
that id could resolve the wrong server's request. It now carries a
per-instance prefix.
Result validation now parses the whole value with `ElicitResultSchema` before
the requested-schema check, instead of inspecting `action` by hand: a
`{ action: "decline", content: { x: {} } }` used to pass, though the standard
result permits only primitives and string arrays in `content`.
The dialog's accessible name now includes the request id and the prompt — two
concurrent elicitations can be for the same app URI, and the previous name
made those indistinguishable, which is what the comment beside it claimed to
prevent.
`observeAppCapabilities` takes a minimal structural `onmessage` contract
(generic over the message type, since a handler typed for a narrower message
is not assignable to one typed for `unknown`), removing the double cast at the
call site.
`smoke:web:elicit` now fails on an uncaught page error after a successful
drive, and captures the async half of that class off the console channel, as
its two sibling smokes do — it could otherwise print OK over a broken bundle.
Adds the tests each fix needs, including the two the review asked for
directly: that `advertiseElicitation` reaches the `AppBridge` constructor, and
that it is absent by default.
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
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>
Both review findings from round two. The routing change sits in the funnel BOTH entry points share, but only the legacy inbound handler was driven by a test — the modern leg, where an elicitation arrives as an `input_required` result the MRTR driver unpacks and retries, was covered by construction alone. Adds `mrtr_app_choose_option` (a modern MRTR tool whose *embedded* elicitation carries `_meta.ui.resourceUri`) and drives it live over a real transport: the app answers, the retry carries that answer through `inputResponses`, and the server echoes it back as the tool's result. A second case cancels the tool call while the app is up and asserts the request-scoped signal aborts; a third runs the same tool against a modern server that never advertised the capability and asserts the native queue takes it. The cancellation case turned up a real gap: `tryAppElicitation` mounted an app for an already-aborted signal (the caller cancelled during an earlier MRTR round), leaving a modal nobody was waiting on and a promise that could never settle. It now throws the abort straight through. Also fixes the smoke count in the root README, which still said "two" headless-Chromium smokes and then introduced a third. Signed-off-by: cliffhall <cliff@futurescale.com>
The command reference still ended `npm run smoke` at `smoke:web:app` and credited the shared prod-web-server helper with three consumers. Adds the new smoke to both, plus its own entry alongside the sibling smokes: what it drives, why the not-negotiated half is the load-bearing one, and the two mechanics that are easy to get wrong (the tabs are a SegmentedControl with no `role="tab"`, and the prompt string also appears in the hidden Protocol payload). Signed-off-by: cliffhall <cliff@futurescale.com>
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>
The capability observer recorded `params.appCapabilities` from ANY frame whose method was `ui/initialize`, before the bridge validated it. A view could therefore send a second, malformed initialize — one the bridge rejects, keeping the capabilities it had already accepted — and flip `elicitation` on in this gate, after which the host would forward an elicitation the bridge never negotiated. Now only a well-formed initialize REQUEST is recorded (a JSON-RPC id, plus the handshake's required `protocolVersion` and `appInfo`), and only the first one: the bridge keeps what it accepted and ignores re-initialization, so this gate agrees rather than offering a second, laxer path to the same flag. A malformed first frame records nothing. This is a gate, not a second copy of the bridge's schema — the bridge stays the authority on the rest of the frame. Signed-off-by: cliffhall <cliff@futurescale.com>
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>
The previous round froze the recorded capabilities at the first handshake, on the belief that the bridge ignores re-initialization. It does not: ext-apps 1.7.5's `_oninitialize` warns about the double-mount and then assigns `_appCapabilities` and `_appInfo` from the new frame — "the latest appInfo/ appCapabilities replace the previous values", in its own words. Freezing left this gate reporting capabilities the bridge no longer held, in both directions: a handshake advertising `elicitation` followed by one without it still read as advertised. Every frame that passes the accept gate now replaces the recorded value, and `appCapabilities` joins `protocolVersion` and `appInfo` in that gate since the bridge's own schema requires all three. A frame the bridge would reject still records nothing AND leaves the previous value alone — it is a route to changing the gate in neither direction. Signed-off-by: cliffhall <cliff@futurescale.com>
…d cast **Round 17 pushed a branch that does not compile, and I reported it green.** `refreshingPersist.ts` and its test were new files; `git commit -s -a` stages modified *tracked* files and not untracked ones, so `App.tsx` went to origin importing a module that was never committed. `npm run ci` passed against my working tree, where both files exist — so the claim was true of what I had and false of what I pushed, which is the worst shape a green result can take. That is the second staging mistake on this branch. The first was `git commit -o <paths>`, which commits whole files rather than my hunks and swept in a collaborator's in-flight tests. Both come from trusting a flag's shorthand instead of reading what was actually staged, so from here the commit goes through an explicit `git add -A` plus a look at `git status --short` before the gate runs — the check is cheap and it is the only one that describes the pushed state rather than the local one. Also replaces the `as unknown as` on the test logger. The rules prohibit it, and it was doing real damage beyond style: forcing a four-method object through `pino.Logger` erased the fact that the stand-in was not one, so a change in how the server logs — a child logger, bindings, a different level — would have gone unnoticed. It is a real pino logger writing to a capturing destination now, which needs no cast because the destination interface is satisfied structurally. Both remaining suppressed comments are the cross-process guarantee, tracked in #2082. Full `npm run ci` green, run against a fully staged tree this time. Signed-off-by: cliffhall <cliff@futurescale.com>
…e schema
The hand-rolled accept check was shallower than the bridge's, so frames it
rejects could still set `elicitation` here — `appInfo: {}` passed, though
`McpUiInitializeRequestSchema` requires an implementation name and version.
Any approximation drifts; the schema itself does not, and ext-apps exports it.
Capabilities are still read from the ORIGINAL frame rather than the parse
output, since that schema is precisely what strips `elicitation` — validating
with it and reading through it would defeat the module. The one check the
schema cannot make is that the frame is a request at all (a notification
carries the same method and params), so the id check stays.
Also stops treating a truthy non-object as an advertisement: the draft declares
`elicitation` as an object, so `elicitation: true` is a value it does not
define and the bridge's schema will reject it the moment it carries the key.
Signed-off-by: cliffhall <cliff@futurescale.com>
…le atomically Round 19 found real data loss rather than process noise, in two places. **An unrelated settings edit destroyed the secret the durability gate had just preserved.** The GET migration withholds its strip for a session-scoped store, so `mcp.json` stays the durable copy — but the POST/PUT paths always wrote the stripped shape. The GET also returns the rehydrated secret to the settings form, which resends the whole object when the user changes anything, so editing a timeout wrote the stripped entry, moved the only durable copy into RAM, and lost it at exit. Every operation reported success. The client config had the identical shape: the read-path guard was there, the write path had none, and saving a CIMD URL did the same thing to the IdP client secret. Both write paths now agree with their read paths: while the store cannot outlive the process, the file stays the durable copy. **The hand-off's delete is no longer a check-to-delete race.** Re-reading immediately before `rm` caught writes that had already landed and was blind to the one arriving between the check and the delete — destroying a *later*, already-successful write, which is worse than the optimistic-write residual and, unlike it, fixable without a lock. The file is claimed by an atomic rename before anything reads it, so the migration operates on a snapshot nobody else can reach and a writer that recreates `secrets.json` is simply untouched. Claiming introduced a failure mode the existing tests caught immediately: a migration that aborts must not leave the secrets stranded at the staging path. The snapshot is restored on any incomplete outcome, and when the live path has been recreated in the meantime it is left in place with its location logged — an operator-visible orphan is recoverable; clobbering someone's write is not. Also: - **`setMany`**, the write-side twin of `getMany`. `set` costs a whole-file read-decrypt-encrypt-write-verify cycle per call, and the settings form resends a server's full `env` map on any edit, so a 10-variable stdio server spent ~30 scrypt derivations saving an unrelated change. One cycle now; the keychain keeps its parallel individual writes. - **A doc that contradicted its function.** `readOnDiskEncryption` still said it "never decrypts, so it needs no passphrase" — round 12 made it authenticate precisely because structure is not access. Full `npm run ci` green, staged tree verified before running it. Signed-off-by: cliffhall <cliff@futurescale.com>
**The task-augmented inbound branch bypassed the routing.** A server→client `elicitation/create` carrying `params.task` answers immediately with a `CreateTaskResult` and settles the TASK when the user answers, so it cannot ride `enqueuePendingElicitation` — and it was therefore never offered to an app, even with a valid `_meta.ui.resourceUri`. It now makes the same attempt, completing the task with the app's result and falling back to the native queue otherwise; an abort fails the task rather than reopening it natively. The two task-settling closures are extracted so both answer routes settle it identically. **The web client advertised the capability without a sandbox.** `useInitialConfig` leaves `sandboxUrl` undefined while `/api/config` is in flight and when the sandbox controller could not start, but the renderer was supplied unconditionally — so the client claimed it could host an app in exactly the window where it could not. The option is now gated on a resolved sandbox URL: such a connection behaves like the CLI/TUI, native queue and no claim made. **Concurrent modals each owned the keyboard.** Every open `Modal.Root` installed its own focus trap, Escape handler and overlay, so the traps fought and one Escape could dismiss more than one pending request. Only the topmost owns them now; the rest stay mounted, keeping their bridges and handshakes, but inert to the keyboard. Signed-off-by: cliffhall <cliff@futurescale.com>
… claim Round 20, and almost all of it is fallout from round 19's two changes. Both were right in direction and wrong in the detail, in the same way: each solved its case by treating a broad thing as a proxy for a narrow one. **The durability guard wrote newly entered secrets to disk in plaintext.** Preserving legacy plaintext for a session-scoped store is correct — stripping it moves the only durable copy into RAM. Using *the whole submitted entry* as the proxy for "legacy" was not: it turned `MCP_INSPECTOR_SECRET_STORE=memory` into "write every new secret to `mcp.json` in the clear", while the footer for that very store says secrets are written nowhere. A security regression introduced by a data-loss fix, and the same overshoot in `client.json`. Provenance decides now. A secret field is kept on disk only when it was already there, unchanged, in the file being overwritten; a newly entered or changed value is stripped and lives in the session store, which is exactly what the footer promises. Where provenance cannot be established — an unparseable prior file — the value is treated as new, because writing a secret to disk on a guess is the failure that matters. **The atomic claim was not as safe as its comments said.** Four ways: - A crash between the claim and the copy left only `secrets.json.migrating-<pid>`. The next run checked the canonical path, found nothing, selected the keychain, and every stored credential vanished with no warning. Orphans are now adopted at startup. - Restoring used `rename`, which on POSIX **replaces** its destination — so the restore path could overwrite a `secrets.json` a concurrent writer had recreated, destroying the later successful write that claiming exists to protect. The comment beside it asserted the opposite. `link` + `unlink` is the no-clobber primitive this needed. - Every claim failure was read as a lost race. `EACCES`/`EROFS` leave the file exactly where it is, and returning quietly selected the keychain while file-backed secrets sat there invisible. Only `ENOENT` is benign now. - A failed cleanup left a plaintext-capable snapshot on disk with a silent catch, while the app reported the keychain as the only store. It names the path now. **A third secret-entry surface had no disclosure at all.** `ServerConfigModal` takes stdio `env` values, which are extracted into the store exactly as Server Settings' are — and the footer's own doc comment claimed there were only two such dialogs. It has the footer now, and every server write that can carry a secret (config submit, both import flows) refreshes the descriptor, not just the settings save. Full `npm run ci` green, staged tree verified first. Signed-off-by: cliffhall <cliff@futurescale.com>
`tasks/cancel` marked the task cancelled and rejected its payload, but left whatever was collecting the answer running — the native pending-request entry, and now an app-rendered elicitation's renderer and bridge. A modal therefore outlived the task it belonged to, and an answer arriving afterwards re-settled it, overwriting `cancelled` with `completed`: a task the server had been told it cancelled. Each receiver task now owns an `AbortController`, aborted by `tasks/cancel` and by session teardown. The task-augmented elicitation branch passes its signal to the app attempt and wires it to the native queue entry, so both answer routes are torn down the same way. Both settle helpers return early on a terminal status, so a late answer through either route cannot resurrect the task. The overwrite predates the app path — a native answer after a cancel did the same thing — so the guard fixes both. Signed-off-by: cliffhall <cliff@futurescale.com>
…the staging name Three of round 21's four findings say the same thing: **the UI half of round 20 was never applied, and I reported it as done.** The edit script aborted partway on a regex error, after writing one file and before writing the other two. So `ServerConfigModal` gained the footer prop but `App.tsx` never passed it — the footer was `undefined` in production and rendered nothing, visible only to the unit test that passes a descriptor directly — the extra refresh call sites never landed, and the footer's doc comment still claimed two secret-entry dialogs while the code had three. `npm run ci` passed throughout, because every test I added exercises the piece that *did* land. This is the same failure as round 17's uncommitted files with a different cause: I verified intent (green gate, clean typecheck) instead of the artifact. Each edit now asserts it changed the file, and the result is grepped afterwards — the check costs a second and is the only one that describes what is actually on disk. Landed now, and verified in the files rather than inferred: - `App.tsx` passes `secretStorage` to `ServerConfigModal`, so the disclosure the previous commit claimed to add actually renders. - Every secret-bearing write refreshes the descriptor — config submit and both import flows, not just the two settings saves. Any of them can perform the pending plaintext-to-encrypted upgrade, after which the footer was reporting "Plaintext file" until reload. - The footer's doc names all three dialogs. The wrong count there was not a description of the bug, it *was* the bug: the omitted dialog was the one taking secrets with no disclosure. Also, the fourth finding: **the staging name was reusable across restarts.** `recoverOrphanedSnapshots` deliberately leaves an orphan when a live `secrets.json` also exists, and a pid-only name repeats (pid 1 on every container start) — so the next claim would `rename` straight over that orphan and permanently discard secrets it may uniquely hold. A per-attempt nonce makes a staging path unrepeatable. Full `npm run ci` green, staged tree verified first. Signed-off-by: cliffhall <cliff@futurescale.com>
…st two writers Round 22. Three findings, all in code I wrote, and the first two are both cases of a stated rationale being wrong rather than a line being wrong. **`refreshingPersist` skipped the refresh on failure, and I had argued for that explicitly.** The doc and its test both said "a persist that threw did not write, so there is nothing new to describe". That is false for this write order: both persistence paths write the secret store *before* the file, so a rejected disk write can follow a `set` that has already upgraded `secrets.json` from plaintext to encrypted. The wrapper then left the footer describing a file that no longer exists in that form — the stale-descriptor bug it exists to prevent, reached through its own error path. It refreshes in a `finally` now. The asymmetry is what settles it: refreshing after a failure that changed nothing costs one idempotent GET, while not refreshing after a failure that changed something leaves a security statement wrong until reload. The test asserting the old behavior is inverted, with the reasoning recorded so it is not "corrected" back. **The KDF parameters were validated for well-formedness, not for cost.** scrypt's work is proportional to `N * r * p`, and all three come off disk — so a file declaring a modest `N` with `p: 1_000_000` burns unbounded CPU on every read *and* on merely describing the store for `/api/config`, which is a page load. Anyone who can write that file can already read its secrets, so this is not a privilege boundary; it is a footgun and a hang, and the fix is free because this build writes exactly the constants it now enforces as maxima. **Two secret-store mutations still bypassed the refresh.** The pagination toggle resends the server's rehydrated secrets, and deletion sweeps them — both are writes, and a write is what performs the pending upgrade. Routed through the same wrapper as the rest. Full `npm run ci` green, staged tree verified first. Signed-off-by: cliffhall <cliff@futurescale.com>
Two of the three findings; the third is answered in the PR thread. **A replaced client could still enqueue.** The one-shot sweep only rejected what was already queued, but the outgoing `InspectorClient` disconnects asynchronously and can enqueue during its own teardown — and that late entry would be rendered by a factory bound to the REPLACEMENT client, reading its resource and answering through a different server. Requests now belong to a session: each constructed client gets one, closing it rejects that session's entries AND refuses anything it queues afterwards, and another session's entries are untouched. **The advertisement no longer has to guess.** Whether a sandbox exists is only known once `/api/config` resolves, and the answer is baked into the client at construction — guessing "available" over-claims, guessing "unavailable" strands the whole session on the native form despite having a sandbox. The connect path now awaits that fetch (already in flight since mount, so no human ever waits; it only orders a deep-link auto-connect racing the same page load), after which an absent `sandboxUrl` means confirmed-absent. Signed-off-by: cliffhall <cliff@futurescale.com>
…resh the stale docs Round 23 produced **no inline comments** — nothing wrong in the code it had not already seen. The five suppressed ones are all in unchanged code: one real misdiagnosis and four pieces of drift. **A decrypted-but-corrupt payload was reported as a wrong passphrase.** By the time `JSON.parse` runs, GCM has verified the tag — so the passphrase is *proven correct*, and a `SecretFileKeyMismatchError` there tells the user to restore a secret they never lost. `asSecretMap` was already outside that catch for exactly this reason; `JSON.parse` was not, so the same class of mistake survived one line above the fix for it. The decrypt now has its own narrow catch and everything after it reports a corrupt file. The test fixture could not express this case — it `JSON.stringify`s its payload, so it can only produce valid JSON. Split into a raw-plaintext variant so a fixture can carry bytes that are authentic under the passphrase and still unparseable, which is the only shape that reaches the branch. The rest is documentation that had drifted from the code it describes: - `handOff`'s doc still described the re-read-and-compare protocol that the atomic claim replaced two rounds ago — an obsolete concurrency invariant is worse than none, because the next reader takes it as the contract. - The spec still described that same hand-off, and still called Client and Server Settings "the only two dialogs that accept a secret". Both now match the code, and the dialog count is called out as load-bearing: the one that was missing from it was the one taking secrets with no disclosure at all. - The `void load()` in `refresh` carries the justification the repo's no-floating-promises rule requires — `load` owns its failures, and `refresh` is deliberately synchronous because `refreshingPersist` calls it from a `finally`. Full `npm run ci` green, staged tree verified first. Signed-off-by: cliffhall <cliff@futurescale.com>
The direct server→client `elicitation/create` handler ignored its request context, so `notifications/cancelled` aborted `ctx.mcpReq.signal` and nothing was listening: the modal and its bridge outlived the cancelled request and could still answer work the server had abandoned. The handler now threads that signal into `enqueuePendingElicitation`, which already wires it to both answer surfaces — the native queue entry and the app renderer. The task-augmented branch deliberately does not use it: that request is answered immediately with a `CreateTaskResult`, so its lifetime is the task's, which carries its own abort. Signed-off-by: cliffhall <cliff@futurescale.com>
Round 24 produced no inline comments again. Of the three suppressed items, one is a pre-existing data-loss bug that predates #1950 entirely. **Editing a server's config deletes its stored OAuth client secret — from the keychain, not only from the stores this PR adds.** Filed as #2084 and fixed here, since the review surfaced it and these lines were already changing. `useServers.updateServer` deliberately sends `{ id, config }` with no `settings`, so a config-only save cannot wipe persisted settings. The route reads that as `preserve` and re-derives settings from the *on-disk* entry — which by #1356's design no longer holds the secrets. The submitted secret set is therefore empty, `expectedSecretFields` always lists the OAuth slot, and the reconcile deleted a value the user never touched. The rename branch was given `mergeRenameKeychainSecrets` for exactly this reason; the in-place branch never got the equivalent. The first fix was too broad — skip obsolete deletion whenever the intent is `preserve` — and an existing test caught it immediately: stdio `env` is part of `config`, so a config-only PUT that drops a key really has retired that secret. Obsolete detection is per-field now: - **`env:` fields** are implied by the entry's shape, so the new entry no longer expecting one is proof it was retired. - **The OAuth slot** is implied by nothing, so absence counts as "cleared" only when the caller spoke about settings at all. Both directions are tested, and the pre-existing env-key reconcile test still passes. Also: - `refreshingPersist` moves to `lib/`. The combinator computes nothing, but what it exists to do is sequence side effects, which is the `lib` half of the repo's split; I judged it the other way on the grounds that the function is a pure higher-order one, and the rule is about what a module is *for*. - `SecretStorageInfo.plaintext` is documented as file-only and *omitted* elsewhere rather than "always false", which is what the producer does and what `usableSecretStorage` enforces — the old wording invited a consumer to build the exact shape the web client discards. Full `npm run ci` green, staged tree verified first. Signed-off-by: cliffhall <cliff@futurescale.com>
…tion The previous round's `await` did not do what it claimed. `setupClientForServer` is synchronous and memoized, so a caller that awaited the config resumed with the `sandboxUrl` captured by the render it STARTED in — undefined, on exactly the load the wait was added for. The deep-link one-shot guard then prevented a retry, so that connection silently lost the capability despite having a sandbox. The OAuth-callback path did not wait at all. Construction now reads a ref written every render, so it sees the current value whichever entry point reached it, and the OAuth path waits like the connect path does. The wait still matters — it is what makes an absent value mean 'confirmed absent' rather than 'not known yet' — but the ref is what makes the value read be the settled one. Signed-off-by: cliffhall <cliff@futurescale.com>
Both `useCallback`s that wrap a persist call in `refreshingPersist` captured `refreshInitialConfig` without listing it. `useInitialConfig`'s `refresh` is not referentially stable — it is a `useCallback` over `load`, whose deps are `[base, authToken, doFetch]` — so once any of those changes, the pagination save and the server removal keep invoking the obsolete closure and the secret-storage footer can go stale. `react-hooks/exhaustive-deps` reported both, but the rule is warn-level and `lint` does not fail on warnings, so `npm run ci` stayed green. These two were the only warnings in the entire web client. Closes #1950 (review round 25) 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>
Only the top modal owned the focus trap, but every lower `Modal.Content` remained an exposed `aria-modal` dialog with reachable controls and a live iframe — so browse navigation could land in a dialog that is visually covered and that itself claims the rest of the page is inert. Non-top contents are now `inert`: out of the accessibility tree and out of focus order, without unmounting, so each app keeps its bridge and its handshake and is live the moment it becomes top. Signed-off-by: cliffhall <cliff@futurescale.com>
`useImportClientConfig` applies every addition and conflict sequentially, so wrapping its per-entry `onAddServer`/`onUpdateServer` in `refreshingPersist` issued one `/api/config` per imported server. On an encrypted file store each of those authenticates the whole file with a scrypt derivation (N=16384), so a 20-server import paid 20 serialized KDFs and round trips for a descriptor that cannot change more than once. Refresh on close instead: the modal must be dismissed before the settings footer that reads the descriptor can be reached, and closing also covers a partially-failed batch. `ServerImportJsonModal` is left wrapped — it adds a single server, so its one refresh is already once per batch. Closes #1950 (review round 26) 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>
…endered-elicitations feat(apps): negotiated app-rendered form elicitations
…secret-store Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # README.md # clients/web/src/App.tsx
…secret-store feat(auth): file-backed and in-memory SecretStore for hosts with no keychain
`useServers`' mutators awaited `refresh()`, whose failure was swallowed into the hook's `error` state rather than thrown. `ServerConfigModal` renders `submitError` only for something `onSubmit` threw, so a successful `POST /api/servers` followed by a failed `GET /api/servers` closed the Add Server modal as though the whole thing had worked — no inline error, no toast, and no new row, since the list read is what would have produced it. That is the silent failure reported in #1914; #1848 removed the trigger (a keyring-less container 500'ing the list read) but not the swallow, so any other cause reproduces it. Split the list read into `loadServers` and give the mutators their own `refreshAfterWrite`, which records the failure in `error` as before and then rethrows. `refreshInternal` is unchanged for the mount effect and the SSE loop — neither has anywhere to put a rejection. The thrown message says the write landed, because it did; only reading the list back failed. A bare "could not add server" would send the user straight into a retry that trips the duplicate-id check, which is the dead end the original report described. Every consumer of these mutators already handles a rejection: ServerConfigModal and ServerRemoveConfirmModal into an inline error, useSettingsDraft and the pagination toggle into a toast, and both import flows into their per-entry outcome list. Closes #1914 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4 Signed-off-by: cliffhall <cliff@futurescale.com>
#1950 shipped FileSecretStore without cross-process mutual exclusion, by decision: an earlier revision hand-rolled a `mkdir` election with an owner stamp, a heartbeat and a stale-takeover, and three consecutive review rounds found a real race in it. The last one is not closable with what Node exposes — claiming a stale lock atomically needs compare-and-swap on a directory entry (`renameat2(RENAME_EXCHANGE)`) — so it was replaced with optimistic verify-and-retry and the residual documented. #2082 settles that as "borrow, don't hand-roll". `proper-lockfile` is what npm itself locks with, and stale-takeover is precisely the problem it has already solved: it re-stats the lock directory after claiming it and gives the lock up when the mtime is not the one it wrote, so the loser of a takeover race releases instead of proceeding. - core/auth/node/file-lock.ts: `withSecretFileLock`. `realpath: false` so a file can be locked into existence (the very first `set` has no `secrets.json`, and the library's default resolves through `fs.realpath`); a warning in place of the library's `onCompromised`, which throws from a timer and would take the session down; and a degrade-with-one-warning path rather than a throw when no lock can be taken — this store exists for boxes missing the usual mechanism (#1848, #1905) and must not gain a new way to be unavailable. - FileSecretStore.mutate holds it across the whole read-modify-write. The optimistic verify stays underneath and is not redundant: a lock is advisory between the processes that take it, so the verify covers a writer outside this codebase and covers the degrade path. The in-process queue stays too, and gains a second job — proper-lockfile is not reentrant, so serializing per path keeps ELOCKED meaning "another process". - absorbFileSecretsIntoKeyring takes the same lock around orphan adoption and the atomic claim, behind a lock-free `readdir` fast path so the common startup (keychain available, no file ever written) neither creates a lock directory nor warns about one it could not create. proper-lockfile is a root `dependency` per the placement rule, and is named in all three bundler `external` lists: tsup externalizes what the *client's* manifest declares, so a root-only CJS package was being inlined into the ESM bundles, leaving esbuild's `Dynamic require of "path" is not supported` shim that killed `--cli` at import time. That rule was undocumented; it is now in AGENTS.md beside the placement rule that creates it, and mirrored into .github/copilot-instructions.md. Tests: 7 new in file-lock.test.ts driving a real second process (the existing suite structurally cannot — `serialize` orders in-process callers before the lock sees them), plus two for the migration fast path. 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>
…env-cwd-revert fix(web): carry a landed stdio env/cwd onto the config it connects with
…tch (#2095) The displayed `paginatedLists` value falls back to the active `servers` entry, and that list only advances when a `GET /api/servers` succeeds — so once a list read fails it keeps describing disk as it was before the writes made since. The optimistic override papered over that, but it was a single app-wide slot cleared on every change of the *active* entry, so switching to another server and back dropped it and the UI fell back to the stale entry. Two things then disagreed, and the one the user could see was the wrong one: the toggle read `off` while disk, the write tracker, and the client just built from it all said `on`; and because the client skipped its connect-time aggregate walk, the all-pages UI rendered an aggregate that was never fetched, with no Load-next-page control to fill it. Hold the override per server instead, in `usePaginatedListsOverride`, paired with that server's entry as of when it was recorded and believed only while the list still carries that same entry object — identity, not equality, so a read reporting the value the override replaced supersedes it too. A server's override then survives a switch away and back, and is still superseded by the first successful read that rebuilds its entry. The read cannot instead move to `lastPersistedSettings.resolve(id)`: that is called during render, and it both reads refs and prunes superseded records. 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>
…#2095) The rollback's explanation still described the removed app-wide boolean and claimed the override was applied only for the active server. That is now the live client's rule alone: the override is keyed by this write's server and is only ever displayed while that server is active, so it is recorded whatever is active by the time the rejection arrives. 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>
…2095) `record` read `serversRef.current` from inside the `setRecords` updater. A functional updater must be pure -- React may defer or replay it -- so the pairing resolved at whatever moment the updater happened to run rather than when the record was written. A list read committing in between would then be paired with the record as its baseline, and the very read that should have superseded the override would instead certify it. Read the list once, at call time, and use it for both the pairing and the prune. 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>
…tion-override-by-server fix: keep the pagination toggle on the last write across a server switch
…2134) parseSSE declared currentEvent/currentData inside the read loop, so an SSE frame whose data: line arrived in one chunk and whose terminating blank line arrived in the next was discarded. buffer already carried a partial LINE across reads; nothing carried a partial FRAME. Because this is the web client's transport, the dropped frame is a JSON-RPC response that never settles: the request hangs forever with no rejection, no console message, and nothing in the Network tab. The payload does not arrive corrupted, it does not arrive at all, which is why it presents as a hang rather than an error. It has not bitten us because Chromium and Firefox happen to deliver whole frames at the payload sizes this transport currently sees. That is luck, not a guarantee: chunk boundaries depend on payload size, TCP segmentation, and any intermediary. The exposure grows with payload size, so a large resources/read is the most likely first victim. The fix hoists both out of the loop and adds an end-of-stream flush — a server that closes without a final blank line has still delivered a complete frame, and dropping it would lose the last message of every such stream. parseSSE is now exported, solely so the test can reach it. That is the only way to hit this deterministically: driving a real transport cannot steer where a chunk boundary lands, but a test that supplies the reader can put it exactly on the terminator. The test keeps the two PASSING cases (whole frame, split mid-payload) alongside the three that failed, because they are what make the diagnosis precise. This is not "SSE parsing is broken" — mid-payload splits are handled correctly today, and only the frame-terminator boundary loses data. Verified against the unfixed code: 3 of 5 fail, all 5 pass after. Signed-off-by: cliffhall <cliff@futurescale.com>
…er URL
When RFC 9728 protected-resource metadata names no authorization server, the
MCP server URL stands in as one. `getAuthorizationServerUrl` built that with
`new URL("/", serverUrl)`, which discards the path — exactly the part discovery
needs when the server is not hosted at the domain root. The SDK's
`buildDiscoveryUrls` derives the path-scoped well-known locations from that
pathname, so an origin-only value could only ever probe the domain root, which
404s for a server like `https://example.com/mod/minilesson/mcp.php`.
Keep the path (and strip query/fragment, which an RFC 8414 issuer never
carries). A straight swap would regress the case the origin-only value did
serve — a server that merely lives under a path while publishing its metadata
at the root — so the URL becomes the first of two candidates:
`getAuthorizationServerUrlCandidates` yields the path-scoped form then the bare
origin, and `discoverAuthorizationServerMetadataForServer` walks them, treating
a candidate's failure as non-fatal while another remains and rethrowing the
first error only when all of them fail.
`discoverScopes` and the CIMD pre-registration probe both go through the walk.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
Caught by CI's clients/web format:check, not by my local run — I had formatted core/ and scripts/ but not the web client's src, which is where the test lives. Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot review round 1. `refreshStoredAuthToken` consumes the same fallback and made a single SDK discovery call, so preserving the path there — without the walk the other two consumers got — would have regressed exactly the case the walk exists to keep: a path-hosted MCP server whose authorization metadata is published at the domain root. It injects its own discovery function as a test seam, so the walk is now also exposed as `discoverAuthorizationServerMetadataFromCandidates(candidates, discover)`, which takes that function and reports *which* candidate answered — the CLI needs the winning URL, since it is the base the token request is made against, not just the metadata. Also drops the `as unknown as typeof fetch` from the new test in favour of `vi.fn<typeof fetch>()`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
…se-chunk-boundary fix(core): carry the in-progress SSE frame across chunk boundaries
…path-scoped-discovery fix(oauth): keep the server's path in the fallback authorization-server URL (#2110)
Every string field in a tool or elicitation form carries an enlarge
button in its right section, so tabbing through a form stopped on one
between each pair of fields. Give it tabIndex={-1}, as #1487 did for the
clear button.
That button was deliberately left in the tab order by #2042, on the
grounds that clearing has a keyboard equivalent and enlarging has none.
So give it one: Enter on the focused single-line field enlarges it and
enters the newline. Nothing was listening for that key there — no
consumer renders SchemaForm inside a <form> — and it is the key a user
presses trying to type the newline the input swallows, which is the
failure #2042 exists to fix.
Enter enters the newline rather than only reshaping the field, since
consuming the keystroke leaves the next word running on from the last. A
field already at its maxLength is enlarged without one. Shift+Enter
enlarges too; the Ctrl/Cmd/Alt chords are left free for a consumer to
bind to running the tool. Clicking the button still only enlarges.
Signed-off-by: cliffhall <cliff@futurescale.com>
Enter is also how a Japanese, Chinese or Korean input method commits the candidate being composed. That keystroke means "accept this word", not "new line", so the shortcut fired on every word such a user finished — enlarging the field and inserting a stray newline each time. Guard on event.nativeEvent.isComposing. The test pairs the composing case with a negative control dispatching the same event with the flag off, so a test that stopped reaching the handler cannot pass for the wrong reason. Signed-off-by: cliffhall <cliff@futurescale.com>
Enter appended its newline to the end of the value regardless of where the caret was, so pressing it in the middle of `abc|def` produced `abcdef` with a trailing blank line rather than splitting the text, and a selected range was kept rather than replaced. That is a rewritten value, not just a misplaced caret, and no editor behaves that way. Split at the field's own selection and replace any selected range, then mount the text area with the caret just after the inserted newline — EnlargedStringField takes an explicit caretAt for this. Pointer activation passes none and keeps falling back to end-of-value, since a click carries no meaningful position. The maxLength check now prices the resulting value, so replacing a selection can free the room the newline needs. The two maxLength tests were asserting against a stub onChange, which froze the value and made "it did not change" pass whatever the component did. They now run on a seeded stateful harness, with a no-maxLength control so the constraint is shown doing the work. Signed-off-by: cliffhall <cliff@futurescale.com>
The handler carried four guards, a preventDefault and a state
transition inline in the TextInput's props, which is what AGENTS.md
rules out ("NEVER use inline code; instead extract to functions in the
same file"). Name it handleEnlargeKeyDown and put it beside
enlargeWithNewline, where the comment can enumerate why each condition
is there rather than sitting between two JSX props.
No behavior change.
Signed-off-by: cliffhall <cliff@futurescale.com>
isComposing covers browsers that fire compositionend after the committing keydown. WebKit is reported to fire it before, which leaves isComposing already false and the CJK user back to a stray newline on every committed word. Also reject keyCode 229, the pre-isComposing sentinel for a keydown the IME consumed, which such an event still carries. Deprecated, and kept anyway: nothing non-deprecated distinguishes that event, and it cannot swallow a real Enter, which reports 13 — tested both ways so the sentinel is provably not eating ordinary keystrokes. The ordering claim is second-hand; the guard is taken on its own merits, since its downside is bounded at zero and it covers any browser whose composition events land too late rather than one vendor. Signed-off-by: cliffhall <cliff@futurescale.com>
Two faults in the Enter path, both found in review. The split read `values[fieldName] as string`, a cast over a Record<string, unknown> fed by whatever a server's schema declares. A string field holding a non-string default renders as text and then threw on `.slice`, so one keystroke crashed the panel. Read the control's own value instead: always a string, and the exact string the selection offsets index into. The caret was recorded only when the newline fit, so pressing Enter mid value in a field at its maxLength enlarged without one and threw the caret to the end — a second surprise on top of the newline that did not arrive. Record it on both paths, `start + 1` after an insertion and `start` otherwise. Signed-off-by: cliffhall <cliff@futurescale.com>
Two more from review, both about field data the schema controls. The caret record was a bare object keyed by field name, and field names come from a server's schema. A field legitimately named `constructor` or `toString` read back an inherited function rather than undefined, so the documented end-of-value fallback never fired and that value reached setSelectionRange. Use a Map, which has no such keys. JSON Schema counts maxLength in Unicode code points; String.length counts UTF-16 code units. A field holding one emoji measured 2, so a maxLength:2 field with room for another character was treated as full and refused its newline. Count code points. The maxLength handed to the DOM input still carries the HTML attribute's own UTF-16 counting; that predates this and is left alone. Signed-off-by: cliffhall <cliff@futurescale.com>
…ge-button-tab-order fix(web): take the enlarge button out of the tab order
Step 1 of the v2.4.0 release: the bump lands on v2/main before the milestone merge, so v2/main is never left behind main afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
…-2-4-0 chore: bump version to 2.4.0
There was a problem hiding this comment.
Pull request overview
Merges the complete v2.4.0 milestone payload into main, preparing the release with application, authorization, storage, transport, UI, testing, and build-tooling updates.
Changes:
- Adds MCP App elicitation/domain support, richer metadata, schema linting, and OAuth fixes.
- Improves secret storage, proxy handling, pagination, SSE parsing, and settings persistence.
- Updates release dependencies, CI gates, tests, documentation, and versioning.
Reviewed changes
Copilot reviewed 129 out of 259 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
.github/copilot-instructions.md |
Mirrors updated review guidance. |
.github/workflows/claude.yml |
Removes obsolete Claude workflow. |
.github/workflows/main.yml |
Adds bundle-external verification. |
AGENTS.md |
Documents new architecture and gates. |
Dockerfile |
Exposes dedicated app-origin port. |
README.md |
Documents v2.4 features and operation. |
clients/cli/README.md |
Documents CLI schema linting. |
clients/cli/__tests__/cli.test.ts |
Covers new CLI behavior. |
clients/cli/__tests__/error-handler.test.ts |
Tests awaited error output. |
clients/cli/__tests__/helpers/fixtures.ts |
Supports stored server settings. |
clients/cli/__tests__/helpers/oauth-test-fakes.ts |
Updates metadata fixtures. |
clients/cli/__tests__/metadata.test.ts |
Tests structured metadata. |
clients/cli/__tests__/method-types.test.ts |
Updates method-type tests. |
clients/cli/__tests__/programmatic-ergonomics.test.ts |
Updates settings fixtures. |
clients/cli/__tests__/schema-lint-report.test.ts |
Tests strict schema reports. |
clients/cli/__tests__/servers-list.test.ts |
Tests server-list output. |
clients/cli/__tests__/stored-auth.test.ts |
Covers OAuth persistence changes. |
clients/cli/package-lock.json |
Locks updated CLI dependencies. |
clients/cli/package.json |
Updates linting and esbuild override. |
clients/cli/src/cli.ts |
Adds structured metadata and strict mode. |
clients/cli/src/error-handler.ts |
Awaits stderr completion. |
clients/cli/src/handlers/collect-app-info.ts |
Accepts structured request metadata. |
clients/cli/src/handlers/connect-timeout.ts |
Updates metadata defaults. |
clients/cli/src/handlers/emit-result.ts |
Integrates strict reporting. |
clients/cli/src/handlers/method-types.ts |
Expands metadata and strict types. |
clients/cli/src/handlers/schema-lint-report.ts |
Implements schema lint reporting. |
clients/cli/src/handlers/servers-list.ts |
Includes persisted settings safely. |
clients/cli/src/utils/awaitable-log.ts |
Adds awaitable stderr writes. |
clients/cli/tsup.config.ts |
Externalizes root runtime dependencies. |
clients/launcher/package.json |
Makes lint warnings fatal. |
clients/launcher/src/index.ts |
Awaits CLI error handling. |
clients/tui/README.md |
Documents schema portability findings. |
clients/tui/__tests__/App.test.tsx |
Covers TUI integration changes. |
clients/tui/__tests__/ToolsTab.test.tsx |
Tests schema findings UI. |
clients/tui/__tests__/helpers/oauth-test-fakes.ts |
Updates metadata fixtures. |
clients/tui/__tests__/schemaToForm.test.ts |
Covers remote-reference schemas. |
clients/tui/eslint.config.js |
Promotes dependency linting to error. |
clients/tui/package-lock.json |
Locks updated TUI dependencies. |
clients/tui/package.json |
Updates linting and esbuild override. |
clients/tui/src/App.tsx |
Adds proxy fetch and structured metadata. |
clients/tui/src/components/ToolsTab.tsx |
Displays schema portability findings. |
clients/tui/tsup.config.ts |
Externalizes root runtime dependencies. |
clients/web/.npmignore |
Corrects published-directory documentation. |
clients/web/.storybook/preview.tsx |
Shares Mantine CSS variables. |
clients/web/README.md |
Documents web v2.4 behavior. |
clients/web/eslint.config.js |
Promotes exhaustive-deps to error. |
clients/web/package-lock.json |
Locks updated web dependencies. |
clients/web/package.json |
Adds editor dependencies and build override. |
clients/web/server/app-origin-controller.ts |
Implements dedicated app-origin hosting. |
clients/web/server/sandbox-controller.ts |
Shares frame-ancestor policy generation. |
clients/web/server/server.ts |
Starts the app-origin controller. |
clients/web/server/vite-base-config.ts |
Excludes node-only lock dependency. |
clients/web/server/vite-hono-plugin.ts |
Integrates app-origin development hosting. |
clients/web/server/web-server-config.ts |
Configures dedicated app-origin ports. |
clients/web/src/App.css |
Updates supported UI styling. |
clients/web/src/App.test.tsx |
Covers integrated v2.4 behavior. |
clients/web/src/App.tsx |
Wires new storage, App, and settings flows. |
clients/web/src/components/elements/AppElicitation/AppElicitationHost.stories.tsx |
Demonstrates App elicitation states. |
clients/web/src/components/elements/AppElicitation/AppElicitationHost.test.tsx |
Tests App elicitation hosting. |
clients/web/src/components/elements/AppElicitation/AppElicitationHost.tsx |
Hosts app-rendered elicitation UI. |
clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx |
Updates renderer stories. |
clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx |
Tests generalized App sources. |
clients/web/src/components/elements/AppRenderer/AppRenderer.tsx |
Supports tool and elicitation sources. |
clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts |
Tests capability negotiation. |
clients/web/src/components/elements/AppRenderer/appCapabilities.ts |
Reads MCP App capabilities. |
clients/web/src/components/elements/AppRenderer/appRenderSource.test.ts |
Tests App source semantics. |
clients/web/src/components/elements/AppRenderer/appRenderSource.ts |
Models generalized App sources. |
clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts |
Tests App bridge behavior. |
clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts |
Supports elicitation bridges and metadata. |
clients/web/src/components/elements/AppRenderer/requestAppElicitation.test.ts |
Tests elicitation bridge requests. |
clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts |
Sends app-rendered elicitation requests. |
clients/web/src/components/elements/EnlargeButton/EnlargeButton.stories.tsx |
Demonstrates keyboard behavior. |
clients/web/src/components/elements/EnlargeButton/EnlargeButton.test.tsx |
Tests enlarge accessibility. |
clients/web/src/components/elements/EnlargeButton/EnlargeButton.tsx |
Removes redundant tab stop. |
clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.stories.tsx |
Demonstrates JSON metadata editing. |
clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx |
Tests JSON editor behavior. |
clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.tsx |
Adds structured JSON object editor. |
clients/web/src/components/elements/SchemaFindingsList/SchemaFindingsList.stories.tsx |
Demonstrates schema findings. |
clients/web/src/components/elements/SchemaFindingsList/SchemaFindingsList.test.tsx |
Tests findings presentation. |
clients/web/src/components/elements/SchemaFindingsList/SchemaFindingsList.tsx |
Renders schema portability findings. |
clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx |
Demonstrates storage disclosures. |
clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx |
Tests storage disclosures. |
clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx |
Displays active secret storage. |
clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx |
Tests secret-storage footer. |
clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx |
Adds permanent storage disclosure. |
clients/web/src/components/groups/ReAuthBanner/ReAuthBanner.tsx |
Improves narrow-layout rendering. |
clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx |
Tests multiline schema fields. |
clients/web/src/components/groups/SchemaForm/SchemaForm.tsx |
Supports multiline string entry. |
clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx |
Tests configuration storage disclosure. |
clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx |
Adds secret-storage footer. |
clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.stories.tsx |
Demonstrates structured metadata settings. |
clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx |
Tests structured metadata editing. |
clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx |
Replaces metadata rows with JSON. |
clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.stories.tsx |
Updates settings stories. |
clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx |
Tests settings persistence changes. |
clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx |
Adds storage disclosure and persistence fixes. |
clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx |
Tests schema findings. |
clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx |
Shows portability findings and reset keys. |
clients/web/src/components/groups/ToolListItem/ToolListItem.test.tsx |
Tests tool warning indicators. |
clients/web/src/components/groups/ToolListItem/ToolListItem.tsx |
Flags unportable tool schemas. |
clients/web/src/components/screens/AppsScreen/AppsScreen.tsx |
Uses generalized App render sources. |
clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx |
Tests duplicate-tool reset behavior. |
clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx |
Resets forms by unique row key. |
clients/web/src/components/views/InspectorView/InspectorView.test.tsx |
Tests OAuth monitoring behavior. |
clients/web/src/components/views/InspectorView/InspectorView.tsx |
Opens Network monitoring on OAuth failure. |
clients/web/src/hooks/useImportClientConfig.test.tsx |
Tests post-write reload warnings. |
clients/web/src/hooks/useImportClientConfig.ts |
Distinguishes write success from reload failure. |
clients/web/src/hooks/useLastPersistedSettings.test.tsx |
Tests persisted-write tracking. |
clients/web/src/hooks/useLastPersistedSettings.ts |
Tracks latest successful settings writes. |
clients/web/src/hooks/usePaginatedListsOverride.test.tsx |
Tests per-server pagination baselines. |
clients/web/src/hooks/usePaginatedListsOverride.ts |
Preserves pagination state by server. |
clients/web/src/lib/appElicitationController.test.ts |
Tests elicitation lifecycle. |
clients/web/src/lib/appElicitationController.ts |
Coordinates app-rendered elicitations. |
clients/web/src/lib/publishAppDocument.test.ts |
Tests dedicated-origin publication. |
clients/web/src/lib/publishAppDocument.ts |
Publishes wrapped App documents. |
clients/web/src/lib/refreshingPersist.test.ts |
Tests refresh-after-persist behavior. |
clients/web/src/lib/refreshingPersist.ts |
Refreshes storage descriptors after writes. |
clients/web/src/main.tsx |
Uses shared Mantine variables. |
clients/web/src/test/aceEditor.ts |
Adds Ace test infrastructure. |
clients/web/src/test/core/auth/challenge.test.ts |
Tests challenge resource metadata. |
clients/web/src/test/core/auth/cimd.test.ts |
Tests CIMD discovery candidates. |
clients/web/src/test/core/auth/connection-state.test.ts |
Tests opaque-token handling. |
clients/web/src/test/core/auth/ema/emaFlow.test.ts |
Covers EMA authorization changes. |
clients/web/src/test/core/auth/providers.test.ts |
Tests refresh-token opt-out. |
clients/web/src/test/core/auth/scopes.test.ts |
Tests persisted scope handling. |
clients/web/src/test/core/auth/secret-storage-info.test.ts |
Tests storage descriptors. |
clients/web/src/test/core/client/node-persistence.test.ts |
Tests client secret persistence. |
clients/web/src/test/core/client/runner.test.ts |
Tests runner authentication options. |
clients/web/src/test/core/json/isSerializableJson.test.ts |
Tests strict JSON validation. |
clients/web/src/test/core/mcp/appElicitation.test.ts |
Tests elicitation negotiation helpers. |
clients/web/src/test/core/mcp/extensions.test.ts |
Tests nested extension advertisement. |
clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts |
Tests InspectorClient App elicitation. |
clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts |
Updates metadata fixtures. |
clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts |
Tests passive challenge observation. |
clients/web/src/test/core/mcp/node/servers.test.ts |
Tests selected stores and bulk reads. |
clients/web/src/test/core/mcp/oauthManager.test.ts |
Tests OAuth recovery changes. |
clients/web/src/test/core/mcp/remote/parseSSE.test.ts |
Tests split SSE frames. |
clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts |
Updates transport metadata fixtures. |
clients/web/src/test/core/mcp/serverList.test.ts |
Tests metadata and stdio mapping. |
clients/web/src/test/core/mcp/state/managedPromptsState.test.ts |
Updates metadata fixtures. |
clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts |
Updates metadata fixtures. |
clients/web/src/test/core/mcp/state/managedResourcesState.test.ts |
Updates metadata fixtures. |
clients/web/src/test/core/mcp/state/managedToolsState.test.ts |
Updates metadata fixtures. |
clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts |
Updates metadata fixtures. |
clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts |
Updates metadata fixtures. |
clients/web/src/test/core/mcp/state/pagedToolsState.test.ts |
Updates metadata fixtures. |
clients/web/src/test/core/react/useInitialConfig.test.tsx |
Tests dynamic storage descriptors. |
clients/web/src/test/core/react/useServers.test.tsx |
Tests reload-error classification. |
clients/web/src/test/core/schemaLint.test.ts |
Tests schema portability linting. |
clients/web/src/test/integration/auth/discovery.test.ts |
Tests path-scoped discovery. |
clients/web/src/test/integration/auth/node/file-lock.test.ts |
Tests cross-process locking. |
clients/web/src/test/integration/auth/node/file-secret-store.test.ts |
Tests file secret storage. |
clients/web/src/test/integration/auth/node/secret-store-selection.test.ts |
Tests storage selection policy. |
clients/web/src/test/integration/auth/node/secret-store.test.ts |
Tests secret-store bulk operations. |
clients/web/src/test/integration/auth/node/secretStoreContract.ts |
Expands shared store contract. |
clients/web/src/test/integration/mcp/appElicitation.test.ts |
Tests end-to-end App elicitation. |
clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts |
Updates metadata coverage. |
clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts |
Updates modern-era fixtures. |
clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts |
Tests OAuth challenge recovery. |
clients/web/src/test/integration/mcp/inspectorClient.test.ts |
Covers integrated client changes. |
clients/web/src/test/integration/mcp/node/transport.test.ts |
Tests real proxy transport. |
clients/web/src/test/integration/mcp/oauth-resource-metadata-challenge.test.ts |
Tests advertised metadata discovery. |
clients/web/src/test/integration/mcp/raw-tool-schemas.test.ts |
Tests unportable schema fixtures. |
clients/web/src/test/integration/mcp/remote/app-document-route.test.ts |
Tests App document publication route. |
clients/web/src/test/integration/mcp/remote/server-config.test.ts |
Tests server configuration changes. |
clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts |
Expands route validation coverage. |
clients/web/src/test/integration/mcp/remote/servers-route.test.ts |
Tests settings write-through behavior. |
clients/web/src/test/integration/mcp/remote/transport.test.ts |
Updates transport settings fixtures. |
clients/web/src/test/integration/server/app-origin-controller.test.ts |
Tests dedicated-origin server. |
clients/web/src/test/integration/server/server-auto-open.test.ts |
Updates server configuration fixture. |
clients/web/src/test/integration/server/server-token-injection.test.ts |
Updates server configuration fixture. |
clients/web/src/test/integration/server/vite-base-config.test.ts |
Tests node-only exclusions. |
clients/web/src/test/integration/server/web-server-config.test.ts |
Tests app-origin port configuration. |
clients/web/src/test/renderWithMantine.tsx |
Applies shared CSS variables in tests. |
clients/web/src/theme/Group.ts |
Adds sticky modal footer variant. |
clients/web/src/theme/cssVariables.ts |
Centralizes accessible Mantine variables. |
clients/web/src/theme/index.ts |
Exports CSS variable resolver. |
clients/web/src/utils/jsonObjectDraft.test.ts |
Tests JSON draft parsing. |
clients/web/src/utils/jsonObjectDraft.ts |
Parses structured metadata drafts. |
clients/web/src/utils/sandbox-csp.test.ts |
Tests updated CSP behavior. |
clients/web/src/utils/sandbox-csp.ts |
Supports dedicated-origin CSP. |
clients/web/static/sandbox_proxy.html |
Loads published dedicated-origin documents. |
clients/web/tsup.runner.config.ts |
Externalizes root runtime dependencies. |
core/auth/challenge.ts |
Carries resource metadata challenges. |
core/auth/cimd.ts |
Uses advertised discovery locations. |
core/auth/connection-state.ts |
Stops replaying rejected opaque tokens. |
core/auth/discovery.ts |
Adds path-scoped discovery fallback. |
core/auth/ema/emaFlow.ts |
Updates EMA discovery behavior. |
core/auth/node/file-lock.ts |
Adds guarded cross-process file locking. |
core/auth/node/file-secret-store.ts |
Implements durable encrypted file storage. |
core/auth/node/secret-store-selection.ts |
Selects and caches default storage. |
core/auth/node/secret-store.ts |
Adds bulk and strict store operations. |
core/auth/providers.ts |
Supports refresh-token opt-out. |
core/auth/scopes.ts |
Handles persisted and offline scopes. |
core/auth/secret-storage-info.ts |
Describes active secret storage. |
core/client/node-persistence.ts |
Preserves stored client secrets. |
core/client/runner.ts |
Applies authentication settings. |
core/json/jsonUtils.ts |
Adds strict serializable JSON support. |
core/json/schemaLint.ts |
Implements shared schema portability lint. |
core/mcp/__tests__/fakeInspectorClient.ts |
Updates fake metadata contracts. |
core/mcp/appElicitation.ts |
Defines App elicitation negotiation. |
core/mcp/extensions.ts |
Advertises nested elicitation support. |
core/mcp/inspectorClient.ts |
Integrates OAuth, elicitation, and metadata changes. |
core/mcp/inspectorClientEventTarget.ts |
Expands event metadata types. |
core/mcp/inspectorClientProtocol.ts |
Expands protocol metadata types. |
core/mcp/node/authChallengeFetch.ts |
Observes authorization challenges. |
core/mcp/node/index.ts |
Exports proxy-fetch helpers. |
core/mcp/node/proxyFetch.ts |
Implements undici proxy fetch. |
core/mcp/node/server-secrets.ts |
Bulk-rehydrates catalog secrets. |
core/mcp/node/servers.ts |
Uses selected secret storage. |
core/mcp/node/transport.ts |
Composes proxy and challenge fetches. |
core/mcp/oauthManager.ts |
Improves OAuth recovery and scopes. |
core/mcp/remote/node/server.ts |
Adds App publication and settings validation. |
core/mcp/remote/remoteClientTransport.ts |
Preserves SSE frames across chunks. |
core/mcp/serverList.ts |
Normalizes metadata and stdio settings. |
core/mcp/state/managedListState.ts |
Uses structured request metadata. |
core/mcp/state/pagedPromptsState.ts |
Uses structured request metadata. |
core/mcp/state/pagedResourceTemplatesState.ts |
Uses structured request metadata. |
core/mcp/state/pagedResourcesState.ts |
Uses structured request metadata. |
core/mcp/types.ts |
Defines new storage and metadata contracts. |
core/react/useInitialConfig.ts |
Refreshes runtime configuration descriptors. |
core/react/usePagedPrompts.ts |
Accepts structured request metadata. |
core/react/usePagedResourceTemplates.ts |
Accepts structured request metadata. |
core/react/usePagedResources.ts |
Accepts structured request metadata. |
core/react/useServers.ts |
Distinguishes reload failures from writes. |
docs/mcp-app-review.md |
Documents third-port forwarding. |
docs/mcp-server-configuration.md |
Documents new server settings. |
docs/v1-to-v2-migration.md |
Updates v2 migration guidance. |
package-lock.json |
Locks v2.4 root dependencies. |
package.json |
Bumps release and root dependencies. |
scripts/lib/announced-child.mjs |
Centralizes announced-child startup. |
scripts/lib/announced-child.test.mjs |
Tests timeout-safe child ownership. |
scripts/lib/ensure-test-servers.mjs |
Rebuilds test servers deterministically. |
scripts/lib/ensure-test-servers.test.mjs |
Tests fixture rebuild behavior. |
scripts/lib/mcp-app-flow.mjs |
Shares installed-App smoke flow. |
scripts/lib/mcp-app-flow.test.mjs |
Tests App smoke helpers. |
scripts/pack-and-verify.mjs |
Verifies Apps from packaged output. |
scripts/smoke-cli.mjs |
Uses shared test-server builder. |
scripts/smoke-tui.mjs |
Uses shared test-server builder. |
scripts/smoke-web-app.mjs |
Tests dedicated-origin App rendering. |
scripts/smoke-web-elicitation.mjs |
Tests app-rendered elicitation. |
scripts/verify-bundle-externals.mjs |
Detects inlined external dependencies. |
scripts/verify-bundle-externals.test.mjs |
Tests bundle-external verification. |
specification/v2_auth_mid_session.md |
Clarifies challenge token handling. |
specification/v2_servers_file.md |
Updates server metadata specification. |
test-servers/configs/app-elicitation-http.json |
Adds negotiated elicitation fixture. |
test-servers/configs/app-elicitation-native-http.json |
Adds native fallback fixture. |
test-servers/configs/mcp-app-domain-http.json |
Adds dedicated-domain fixture. |
test-servers/configs/oauth-custom-resource-metadata-http.json |
Adds custom metadata fixture. |
test-servers/configs/unportable-schemas-http.json |
Adds schema lint fixture. |
test-servers/src/composable-test-server.ts |
Supports new fixture capabilities. |
test-servers/src/load-config.ts |
Parses new fixture options. |
test-servers/src/preset-registry.ts |
Registers new tools and resources. |
test-servers/src/resolve-config.ts |
Propagates new fixture settings. |
test-servers/src/test-server-fixtures.ts |
Implements new test presets. |
test-servers/src/test-server-http.ts |
Records structured metadata. |
test-servers/src/test-server-oauth.ts |
Serves custom resource metadata. |
test-servers/tsconfig.json |
Enables incremental fixture builds. |
vitest.shared.mts |
Pins root proper-lockfile resolution. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
References #2143
Fourth
v2/main→mainmilestone merge: the v2.4.0 payload — 31 PRs, 201 non-merge commits, including the version bump.Milestone v2.4.0 Smoke Test Ledger
All of the contributions for milestone v2.4.0 were individually smoke tested — driven against real servers through the web client and the CLI, not just their tests. The results are here: https://claude.ai/code/artifact/900ca77c-8d1e-4c8b-b05a-e5edbde6c9e3
32 / 32 closed issues verified, 0 regressions. The final pass was re-run from this branch (
v2/chore/milestone-merge-v2.4.0@c9cae0ac) in its own clean worktree with its own fullnpm install— the gate,pack:verify, and every live drive — so no row rests on a tree other than the one that merges.git diff origin/v2/main HEADis empty, and every result reproduced identically to the pass onv2/main.Merge + release
buildto pass.git push origin mainis rejected:mainis ruleset-protected and no one can bypass.2.4.0→ Create new tag on publish, Target =main→ Publish.pack:verify, asserts the tag matchespackage.json, and publishes thelatestdist-tag.2.4.0, novprefix.References, notCloses.Version
No bump commit in this PR —
2.3.0 → 2.4.0landed onv2/mainfirst (#2141 / #2142) and arrives here as part of the payload, sov2/mainis never left behindmainafterwards. This is the #2010 procedure, now on its second consecutive release.Conflicts
None.
mainhas not moved since the v2.3.0 merge (#2054) — no Dependabot PR has landed on it in the interval — so its tip was still an ancestor of this branch's first parent and every path three-way merged clean. The merged tree is byte-identical toorigin/v2/main.Because the v2.3.0 merge did conflict on the lockfile, and because #2057 is specifically about
v2/mainrunning versions already fixed onmain, the one-way drift was re-checked rather than assumed: comparing 1,909 package entries across all five lockfiles, zero are older here than onmain. A subsequentnpm installproduced zero lockfile drift, so the merged lockfiles are internally consistent rather than merely conflict-free.Verification
npm run cipasses from the root on this branch — validate → coverage → verify:build-gate → verify:bundle-externals → smoke ×7 → Storybook — as doesnpm run pack:verifyagainst the published tarball. Plainnpm auditreports 0 vulnerabilities in all five installs, dev included.What's in it
_meta.ui— #2055), #2100 (honor_meta.ui.domainwith a dedicated origin — #2056), #2106 (CSP drop warning names the field, not a safety verdict — #2064), #2107 (pack:verifyrenders an App from the installed tarball — #2003)resource_metadataintoauth()— #2071), #2115 (open the monitoring sidebar to Network when OAuth fails — #2108), #2114 (Request refresh tokenopt-out for Entra admin-consent tenants — #2068), #2105 (stop replaying an expired opaque token — #2051), #2118 (persist the requested scope when the token response omits it — #2117)SecretStorefor containers — #1950; and a config-only edit no longer deletes the stored client secret — #2084), #2088 (cross-process lock on the secrets file — #2082)--strictschema-portability lint — #1005), #2109 (multiline string entry — #2042), #2139 (take its Enlarge button out of the tab order — #2138), #2094 (complex JSON request_meta— #1910), #2112 (CLI applies a server's persistedmetadata— #2093)parseSSEno longer drops a frame split across a chunk boundary — #2134)--max-warnings 0in all six lint scopes — #2085), #2120 (smokes rebuildtest-serversunconditionally — #2111), #2116 (esbuild override clears the dev-only advisory — #2062), #2103 (remove the Claude Code workflow — #2102), #2070 (drop the redundant PR-head checkout — #2069), #2092 (three v2.2.0 merge-review cleanups — #2000)Two issues in the milestone closed without a PR of their own: #922 (registry
server.jsonsupport already shipped) and #2021 (an open question, closed as decided with the behavior unchanged). One PR here, #2139, closes a v2.5.0 issue (#2138) that landed early because it modifies #2042's control.