Skip to content

chore: merge v2/main for the v2.4.0 milestone release - #2145

Merged
cliffhall merged 272 commits into
mainfrom
v2/chore/milestone-merge-v2.4.0
Aug 26, 2026
Merged

chore: merge v2/main for the v2.4.0 milestone release#2145
cliffhall merged 272 commits into
mainfrom
v2/chore/milestone-merge-v2.4.0

Conversation

@cliffhall

@cliffhall cliffhall commented Aug 26, 2026

Copy link
Copy Markdown
Member

References #2143

Fourth v2/mainmain milestone 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 full npm 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 HEAD is empty, and every result reproduced identically to the pass on v2/main.

Merge + release

  1. Approve (1 review required; Copilot's doesn't count) and wait for build to pass.
  2. Merge with the button — use Create a merge commit. A local merge + git push origin main is rejected: main is ruleset-protected and no one can bypass.
  3. Release — Releases → Draft a new release → Choose a tag → type 2.4.0Create new tag on publish, Target = main → Publish.
    • Publishing runs pack:verify, asserts the tag matches package.json, and publishes the latest dist-tag.
    • Bare 2.4.0, no v prefix.
  4. Close Release 2.4.0 step 2: merge v2/main into main and cut the release #2143 manually — this PR says References, not Closes.
  5. Close out the v2.4.0 milestone, moving anything still open on it to a later one.

Version

No bump commit in this PR2.3.0 → 2.4.0 landed on v2/main first (#2141 / #2142) and arrives here as part of the payload, so v2/main is never left behind main afterwards. This is the #2010 procedure, now on its second consecutive release.

Conflicts

None. main has 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 to origin/v2/main.

Because the v2.3.0 merge did conflict on the lockfile, and because #2057 is specifically about v2/main running versions already fixed on main, the one-way drift was re-checked rather than assumed: comparing 1,909 package entries across all five lockfiles, zero are older here than on main. A subsequent npm install produced zero lockfile drift, so the merged lockfiles are internally consistent rather than merely conflict-free.

Verification

npm run ci passes from the root on this branch — validate → coverage → verify:build-gate → verify:bundle-externals → smoke ×7 → Storybook — as does npm run pack:verify against the published tarball. Plain npm audit reports 0 vulnerabilities in all five installs, dev included.

What's in it

Group PRs
Release mechanics #2142 (bump to 2.4.0 — #2141)
MCP Apps #2083 (negotiated app-rendered form elicitations — #1854), #2098 (read UI metadata from _meta.ui#2055), #2100 (honor _meta.ui.domain with a dedicated origin — #2056), #2106 (CSP drop warning names the field, not a safety verdict — #2064), #2107 (pack:verify renders an App from the installed tarball — #2003)
Authorization #2136 (path-scoped discovery for servers not at the domain root — #2110), #2113 (carry the challenge's resource_metadata into auth()#2071), #2115 (open the monitoring sidebar to Network when OAuth fails — #2108), #2114 (Request refresh token opt-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)
Secrets & storage #2076 (file-backed SecretStore for containers — #1950; and a config-only edit no longer deletes the stored client secret — #2084), #2088 (cross-process lock on the secrets file — #2082)
Tools, forms & metadata #2121 (--strict schema-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 persisted metadata#2093)
Servers, lists & settings #2087 (surface a failed post-write list reload — #1914), #2091 (per-server pagination rollback baseline — #2089), #2131 (keep the toggle on the last write across a server switch — #2095), #2119 (carry a landed stdio env/cwd onto the config it connects with — #2096)
Networking & transport #2099 (route proxied requests through undici and keep it external — #2067), #2135 (parseSSE no longer drops a frame split across a chunk boundary — #2134)
Build, lint & release tooling #2090 (--max-warnings 0 in all six lint scopes — #2085), #2120 (smokes rebuild test-servers unconditionally — #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.json support 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.

cliffhall and others added 30 commits August 22, 2026 19:43
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>
cliffhall and others added 24 commits August 25, 2026 09:19
…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>
Copilot AI balanced review requested due to automatic review settings August 26, 2026 16:42
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Aug 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread package.json
@cliffhall
cliffhall merged commit edf54f5 into main Aug 26, 2026
7 checks passed
@cliffhall
cliffhall deleted the v2/chore/milestone-merge-v2.4.0 branch August 26, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Release 2.4.0 step 2: merge v2/main into main and cut the release

2 participants