fix(auth): address Copilot review round 10, and document the concurrency design
The headline is a correction to my own claim rather than a new defect. **The optimistic verify is weaker than the comment said it was.** It described the residual as a crash between the write and the verifying read. The reviewer pointed out that no crash is needed: order two writers as write-A, verify-A, write-B, verify-B and both report success while A's entry is gone — A's verify simply ran before there was anything to see, and B did nothing wrong. A crash is one instance of that shape, not the whole of it. The trace in the old comment quietly assumed A verifies *after* B writes, which nothing guarantees. Corrected in the doc comment, the README and the spec, and the honest summary is now stated rather than implied: this converges in every interleaving where the clobber lands before the verify, it is not mutual exclusion, and a deployment that needs the guarantee wants an OS-backed lock from a dedicated library — not another hand-rolled election. It remains better than what it replaced, which lost updates across a wider set of interleavings and could not be closed at all with what Node exposes. The other findings: - **An envelope naming the cipher still is not an openable one.** Round 9 added a `kdf`/`data` presence check; three dot-separated strings satisfied it while an empty tag, a 4-byte IV or `N: 3` still made every decrypt throw — so the quiet "Encrypted file" footer went to a file whose next save was guaranteed to fail. Now validates part sizes, a non-empty ciphertext, and the scrypt cost parameters (node rejects a non-power-of-two `N` synchronously, out of the derivation rather than the decrypt). A positive control asserts a file we actually wrote still reads as encrypted. - **`encryptionUnknown` + `looseMode` claimed an exposure it had not established.** With both set, `plaintext` is absent, so a `!== false` test landed on "anyone who can read it can read the secrets in it" — about a file that may well hold ciphertext we simply could not classify. Three-way now, in `secretStorageCaveat` and mirrored in the footer's tooltip, with a test on each. - **The README described a lock that no longer exists**, which is the worst kind of stale doc: it stated a durability guarantee the code had stopped providing. Rewritten to describe the actual design, including the residual. - **The private-method access in the concurrency test** now carries the justification the repo's TypeScript rules require: the asserted shape is `writeMap`'s real signature, so a rename breaks the line instead of silently detaching the stub, and a production injection seam would let the real write path drift from the tested one. Also updates `specification/v2_servers_file.md`, which still described #1356's world: one keychain-only store, and unavailability as a hard error. It now covers store selection and the probe, the file format and lazy encryption upgrade, the concurrency design and its residual, the `getStrict`/`getMany` seams and the two traps `getStrict` hit, the durability gate on migration, the keychain hand-off and its conditional delete, and how the active store is surfaced. Full `npm run ci` green. (One unrelated `AppRenderer` theme-flip test failed once under full-suite parallelism and passes in isolation with and without these changes — a flake, not a regression.) Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
339aea3729
commit
219765efff
8 changed files with 267 additions and 72 deletions
|
|
@ -482,7 +482,9 @@ Setting the passphrase later is safe — the next write upgrades an existing pla
|
|||
|
||||
The Inspector writes the file `0600` and re-tightens it at startup if something loosened it. If it _cannot_ — the file belongs to another user, or the mount is read-only — it says so in the log rather than continuing to describe the file as protected, since on that box the mode claim above is not true.
|
||||
|
||||
**One durable file, one writer.** Mutations take a lock (`secrets.json.lock`) so a second Inspector on the same box — a CLI run beside a web session — cannot read the same state and overwrite the first one's save. A lock left behind by a killed process is treated as abandoned after 10 seconds and stolen, because a lock that only a clean exit can release would turn one `docker kill` into permanently broken secret writes.
|
||||
**Two Inspectors, one file.** Within a process, mutations are serialized per file path, so a web session's own concurrent saves cannot lose each other. Across processes — a CLI run beside a web session — there is deliberately **no lock**: writers are allowed to collide and the loser is made to notice. Each mutation reads the file, applies its change, writes, then reads back and compares the whole map; if another process wrote in between, it re-applies onto what they left and retries, and after five lost rounds it fails loudly rather than returning as though the value were saved.
|
||||
|
||||
This is **not mutual exclusion**, and the residual case is worth stating: the verify only catches a clobber that has already landed, so if one Inspector reads back *before* the other's write arrives, both report success and one value is gone. That needs two Inspectors writing the same file within the gap between one's write and its read-back — narrow, but not only a crash. An earlier build did take a lock (`secrets.json.lock`); it was removed because making a `mkdir` lock single-winner on a stale takeover needs a compare-and-swap on a directory entry that Node does not expose, so it had the same class of failure with several hundred more lines and no way to close it.
|
||||
|
||||
Three env vars affect where the file lands. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, bypassing the probe. `MCP_INSPECTOR_SECRET_FILE` names the file. Failing both, the file follows `MCP_STORAGE_DIR` — the same variable that relocates OAuth tokens and `client.json` — so mounting a volume at your configured storage directory is enough to make secrets durable there.
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,36 @@ describe("SecretStorageFooter", () => {
|
|||
expect(band).toHaveAttribute("data-tone", "warn");
|
||||
});
|
||||
|
||||
it("does not claim exposure in the tooltip when encryption is unknown", async () => {
|
||||
// Mirrors `secretStorageCaveat`: `plaintext` is absent alongside
|
||||
// `encryptionUnknown`, and a two-way `!== false` test would assert that
|
||||
// a reader gets the secrets out of a file we could not even classify.
|
||||
// The claim lives in the *tooltip*, so that is what this hovers to read
|
||||
// — the visible line reports the unreadable envelope, which is a
|
||||
// different (and also correct) statement.
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<SecretStorageFooter
|
||||
info={{
|
||||
...plaintextFile,
|
||||
plaintext: undefined,
|
||||
looseMode: 0o644,
|
||||
encryptionUnknown: "not valid JSON",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const band = screen.getByTestId("secret-storage-footer");
|
||||
expect(band).toHaveAttribute("data-tone", "warn");
|
||||
|
||||
// The tooltip's target is the copy button, not the band around it.
|
||||
await user.hover(screen.getByRole("button", { name: /Copy secrets file/ }));
|
||||
const tip = await screen.findByText(/Mode 0644 could not be tightened/);
|
||||
expect(tip).toHaveTextContent("could not determine");
|
||||
expect(tip).not.toHaveTextContent(
|
||||
"anyone who can read the file can read the secrets in it",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing when the backend didn't report a store", () => {
|
||||
// A guessed answer under a secret field is worse than no answer, so the
|
||||
// unknown case is silence rather than a default.
|
||||
|
|
|
|||
|
|
@ -197,9 +197,16 @@ function footerTooltip(info: SecretStorageInfo): string | undefined {
|
|||
// secrets. Claiming otherwise overstates the exposure, and a warning
|
||||
// that cries wolf is discounted on the occasion it is literally true.
|
||||
parts.push(
|
||||
info.plaintext === false
|
||||
? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and the passphrase is then the only thing protecting its contents.`
|
||||
: `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`,
|
||||
// Three cases, mirroring `secretStorageCaveat`. With
|
||||
// `encryptionUnknown` set, `plaintext` is absent and `!== false` would
|
||||
// land on the "anyone can read the secrets" wording — asserting an
|
||||
// exposure we did not establish, since the envelope may hold
|
||||
// ciphertext we simply could not classify.
|
||||
info.encryptionUnknown !== undefined
|
||||
? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and whether that exposes the secrets depends on encryption this build could not determine (${info.encryptionUnknown}).`
|
||||
: info.plaintext === false
|
||||
? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and the passphrase is then the only thing protecting its contents.`
|
||||
: `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`,
|
||||
);
|
||||
} else if (info.permissionsUnknown !== undefined) {
|
||||
parts.push(
|
||||
|
|
|
|||
|
|
@ -113,6 +113,27 @@ describe("secretStorageCaveat", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("does not claim exposure when the encryption state is unknown", () => {
|
||||
// `looseMode` with `encryptionUnknown` leaves `plaintext` absent, and a
|
||||
// `!== false` test then lands on the plaintext wording — asserting that
|
||||
// a reader gets the secrets. The envelope may hold ciphertext; we could
|
||||
// not classify it. Claiming the worse of two unknowns is still claiming
|
||||
// something we never established.
|
||||
const loose: SecretStorageInfo = {
|
||||
kind: "file",
|
||||
reason: "fallback",
|
||||
durable: true,
|
||||
looseMode: 0o644,
|
||||
encryptionUnknown: "not valid JSON",
|
||||
path: "/tmp/secrets.json",
|
||||
};
|
||||
const caveat = secretStorageCaveat(loose);
|
||||
expect(secretStorageTone(loose)).toBe("warn");
|
||||
expect(caveat).toContain("0644");
|
||||
expect(caveat).toContain("could not determine");
|
||||
expect(caveat).not.toContain("anyone who can read it can read the secrets");
|
||||
});
|
||||
|
||||
it("warns about a loose mode even on an encrypted file", () => {
|
||||
// The passphrase is then the only thing between a reader and the values,
|
||||
// which is worth saying rather than rendering the quiet neutral state.
|
||||
|
|
|
|||
|
|
@ -936,6 +936,50 @@ describe("readOnDiskEncryption rejects an envelope it could not open", () => {
|
|||
},
|
||||
"encrypted payload is not iv.tag.ciphertext",
|
||||
],
|
||||
// Three dot-separated strings are not enough: each of these satisfies a
|
||||
// part-count check and still makes every decrypt throw, so reporting
|
||||
// "encrypted" would hand the quiet footer to a file whose next save is
|
||||
// guaranteed to fail.
|
||||
[
|
||||
"an initialization vector of the wrong length",
|
||||
{
|
||||
version: 1,
|
||||
encryption: "aes-256-gcm",
|
||||
kdf: { algorithm: "scrypt", salt: "AAAA", N: 16384, r: 8, p: 1 },
|
||||
data: `${Buffer.alloc(4).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
|
||||
},
|
||||
"initialization vector is 4 bytes, expected 12",
|
||||
],
|
||||
[
|
||||
"an authentication tag of the wrong length",
|
||||
{
|
||||
version: 1,
|
||||
encryption: "aes-256-gcm",
|
||||
kdf: { algorithm: "scrypt", salt: "AAAA", N: 16384, r: 8, p: 1 },
|
||||
data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(3).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
|
||||
},
|
||||
"authentication tag is 3 bytes, expected 16",
|
||||
],
|
||||
[
|
||||
"a KDF cost parameter node would reject",
|
||||
{
|
||||
version: 1,
|
||||
encryption: "aes-256-gcm",
|
||||
kdf: { algorithm: "scrypt", salt: "AAAA", N: 3, r: 8, p: 1 },
|
||||
data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
|
||||
},
|
||||
"kdf N is 3, which is not a power of two above 1",
|
||||
],
|
||||
[
|
||||
"an unsupported KDF",
|
||||
{
|
||||
version: 1,
|
||||
encryption: "aes-256-gcm",
|
||||
kdf: { algorithm: "pbkdf2", salt: "AAAA", N: 16384, r: 8, p: 1 },
|
||||
data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`,
|
||||
},
|
||||
'unsupported kdf "pbkdf2"',
|
||||
],
|
||||
])("reports unreadable for %s", async (_label, envelope, detail) => {
|
||||
await fs.writeFile(filePath(), JSON.stringify(envelope), "utf-8");
|
||||
const store = new FileSecretStore({ filePath: filePath() });
|
||||
|
|
@ -946,6 +990,19 @@ describe("readOnDiskEncryption rejects an envelope it could not open", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("readOnDiskEncryption accepts a real envelope", () => {
|
||||
it("still reports encrypted for a file this build actually wrote", async () => {
|
||||
// The negative cases above are only meaningful next to this: tightening
|
||||
// the validation must not start rejecting the files we produce.
|
||||
const store = new FileSecretStore({
|
||||
filePath: filePath(),
|
||||
passphrase: "hunter2",
|
||||
});
|
||||
await store.set("srv", "env:A", "1");
|
||||
expect(await store.readOnDiskEncryption()).toEqual({ state: "encrypted" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("concurrent writers (no lock, optimistic verify-and-retry)", () => {
|
||||
// The lock these replace was removed after three review rounds each found
|
||||
// a real race in it; the last needed a compare-and-swap on a directory
|
||||
|
|
@ -1017,6 +1074,15 @@ describe("concurrent writers (no lock, optimistic verify-and-retry)", () => {
|
|||
// queue and writing through one from inside a queued section would
|
||||
// deadlock on it.
|
||||
const clobberAfterEveryWrite = (store: FileSecretStore): void => {
|
||||
// Double cast, justified per the repo's TypeScript rules. The property
|
||||
// reached is `private`, so no single cast expresses it. What makes it
|
||||
// safe is that the asserted shape is `writeMap`'s real signature: the
|
||||
// replacement is checked against it, so a rename or a signature change
|
||||
// fails this line rather than silently leaving the stub detached and the
|
||||
// test passing for the wrong reason. The alternative — a production
|
||||
// injection seam — would add API surface existing only for this test,
|
||||
// and would let the real write path drift from the tested one, which is
|
||||
// precisely what this test exists to catch.
|
||||
const inner = store as unknown as {
|
||||
writeMap: (m: Record<string, string>) => Promise<void>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ const FORMAT_VERSION = 1;
|
|||
const CIPHER = "aes-256-gcm";
|
||||
const KEY_BYTES = 32;
|
||||
const IV_BYTES = 12;
|
||||
/** AES-GCM authentication tag length, in bytes. */
|
||||
const GCM_TAG_BYTES = 16;
|
||||
/** scrypt defaults. `N` is the only one that meaningfully costs time; 2^14
|
||||
* derives in a few milliseconds, which is invisible next to the file I/O it
|
||||
* accompanies and is derived once per read/write rather than per secret. */
|
||||
|
|
@ -235,6 +237,48 @@ export type SecretFileEncryptionState =
|
|||
| { state: "encrypted" }
|
||||
| { state: "unreadable"; detail: string };
|
||||
|
||||
/** A positive, finite integer? */
|
||||
const isPositiveInt = (v: unknown): v is number =>
|
||||
typeof v === "number" && Number.isInteger(v) && v > 0;
|
||||
|
||||
/**
|
||||
* Why this encrypted envelope could not be opened, or `null` if it can.
|
||||
*
|
||||
* Everything `readMap` needs before it reaches `createDecipheriv`, checked
|
||||
* in the same order it would fail: the parts, then their sizes, then the
|
||||
* derivation parameters. `scrypt` validates its own cost parameters
|
||||
* *synchronously*, so `N: 3` throws out of the derivation rather than
|
||||
* arriving as a decrypt failure — which is why they are checked here rather
|
||||
* than left to the cipher.
|
||||
*/
|
||||
function encryptedEnvelopeProblem(parsed: SecretFile): string | null {
|
||||
if (!parsed.kdf || typeof parsed.data !== "string") {
|
||||
return "encrypted envelope is missing its kdf or data";
|
||||
}
|
||||
const parts = decodeParts(parsed.data);
|
||||
if (!parts) return "encrypted payload is not iv.tag.ciphertext";
|
||||
if (parts.iv.length !== IV_BYTES) {
|
||||
return `initialization vector is ${parts.iv.length} bytes, expected ${IV_BYTES}`;
|
||||
}
|
||||
if (parts.tag.length !== GCM_TAG_BYTES) {
|
||||
return `authentication tag is ${parts.tag.length} bytes, expected ${GCM_TAG_BYTES}`;
|
||||
}
|
||||
if (parts.body.length === 0) return "ciphertext is empty";
|
||||
const { algorithm, salt, N, r, p } = parsed.kdf;
|
||||
if (algorithm !== "scrypt") return `unsupported kdf "${String(algorithm)}"`;
|
||||
if (typeof salt !== "string" || Buffer.from(salt, "base64").length === 0) {
|
||||
return "kdf salt is missing or not base64";
|
||||
}
|
||||
// `N` must be a power of two greater than 1; node rejects anything else.
|
||||
if (!isPositiveInt(N) || N < 2 || (N & (N - 1)) !== 0) {
|
||||
return `kdf N is ${String(N)}, which is not a power of two above 1`;
|
||||
}
|
||||
if (!isPositiveInt(r) || !isPositiveInt(p)) {
|
||||
return `kdf r/p are ${String(r)}/${String(p)}, which are not positive integers`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface FileSecretStoreOptions {
|
||||
/** Absolute path of the secrets file. */
|
||||
filePath: string;
|
||||
|
|
@ -313,23 +357,14 @@ export class FileSecretStore implements SecretStore {
|
|||
}
|
||||
if (parsed.encryption === "none") return { state: "plaintext" };
|
||||
if (parsed.encryption === CIPHER) {
|
||||
// Naming the cipher is not the same as being openable. An envelope
|
||||
// with no `kdf`/`data` — or a `data` that is not the three-part
|
||||
// payload — is refused by `readMap`, so reporting it as "encrypted"
|
||||
// gives the quiet, reassuring footer for a file whose very next save
|
||||
// is guaranteed to fail. Check the shape the decryptor requires.
|
||||
if (!parsed.kdf || typeof parsed.data !== "string") {
|
||||
return {
|
||||
state: "unreadable",
|
||||
detail: "encrypted envelope is missing its kdf or data",
|
||||
};
|
||||
}
|
||||
if (!decodeParts(parsed.data)) {
|
||||
return {
|
||||
state: "unreadable",
|
||||
detail: "encrypted payload is not iv.tag.ciphertext",
|
||||
};
|
||||
}
|
||||
// Naming the cipher is not the same as being openable, and a partial
|
||||
// check is its own trap: three dot-separated strings satisfy
|
||||
// `decodeParts` while an empty tag, a 4-byte IV or `N: 3` still make
|
||||
// every decrypt throw. Reporting "encrypted" then gives the quiet,
|
||||
// reassuring footer to a file whose very next save is guaranteed to
|
||||
// fail, which is the one thing this state exists to prevent.
|
||||
const problem = encryptedEnvelopeProblem(parsed);
|
||||
if (problem) return { state: "unreadable", detail: problem };
|
||||
return { state: "encrypted" };
|
||||
}
|
||||
return {
|
||||
|
|
@ -509,23 +544,32 @@ export class FileSecretStore implements SecretStore {
|
|||
* someone wrote between our write and our read — so re-apply onto what
|
||||
* they left and try again.
|
||||
*
|
||||
* The comparison is over the **whole map**, not just the entry we touched,
|
||||
* and that is the entire reason this works. Checking only our own key
|
||||
* passes in precisely the case that loses data: our entry is present, and
|
||||
* it is the *other* writer's that is gone. Trace two writers A and B from
|
||||
* the same `M0` — A writes `MA`, B writes `MB` and clobbers A's entry. B
|
||||
* verifies and sees `M2 === MB`, correctly: B did nothing wrong. A
|
||||
* verifies, sees `M2 !== MA`, re-applies onto `MB`, and writes again. Both
|
||||
* entries survive, and the writer that was clobbered is the one that
|
||||
* repairs it.
|
||||
* The comparison is over the **whole map**, not just the entry we touched.
|
||||
* Checking only our own key would pass in precisely the case that loses
|
||||
* data: our entry is present, and it is the *other* writer's that is gone.
|
||||
* When the verify does fire, the writer that was clobbered is the one that
|
||||
* repairs it — A writes `MA`, B writes `MB` over it, B verifies `MB`
|
||||
* correctly, A verifies, sees `M2 !== MA`, re-applies onto `MB`.
|
||||
*
|
||||
* **The residual window, stated plainly rather than left to be
|
||||
* rediscovered:** a process that crashes between its write and its
|
||||
* verifying read cannot notice it was clobbered, and nothing else will.
|
||||
* That is strictly narrower than the lock's failure — which lost updates
|
||||
* with every participant alive and well — and unlike the lock it needs no
|
||||
* primitive Node lacks. It is not mutual exclusion and this file does not
|
||||
* claim to provide it.
|
||||
* **This is not mutual exclusion, and the gap is wider than a crash.**
|
||||
* The verify only catches a clobber that has already landed. Order the
|
||||
* same two writers as write-A, verify-A, write-B, verify-B and both
|
||||
* succeed while A's entry is gone: A's verify ran before B's write, so
|
||||
* there was nothing yet to see, and B did nothing wrong. Nothing detects
|
||||
* it afterwards. A crash between write and verify is one instance of the
|
||||
* same shape, not the whole of it — an earlier version of this comment
|
||||
* said otherwise, which understated it.
|
||||
*
|
||||
* What that buys, stated without overselling: two processes must write the
|
||||
* same file within the window between one's write and its read-back, and
|
||||
* the loss is one secret that reported success. The lock this replaced
|
||||
* lost updates in a *wider* set of interleavings, with every participant
|
||||
* alive, and could not be closed without a primitive Node does not expose
|
||||
* (`renameat2(RENAME_EXCHANGE)`); this costs ~220 fewer lines and converges
|
||||
* in every interleaving where the clobber lands before the verify. If the
|
||||
* residual matters for a deployment, the answer is a real lock — an
|
||||
* OS-backed one from a dedicated library — not another hand-rolled
|
||||
* election.
|
||||
*
|
||||
* Reads deliberately do not participate: `writeStoreFile` is atomic
|
||||
* (write-temp-then-rename), so a reader sees either the old file or the
|
||||
|
|
|
|||
|
|
@ -169,6 +169,15 @@ export function secretStorageCaveat(
|
|||
// kind of wrong: a reader of an *encrypted* file gets ciphertext, not
|
||||
// secrets. Saying otherwise trains people to discount the warning on the
|
||||
// occasion it is literally true.
|
||||
// Three cases, not two. With `encryptionUnknown` set, `plaintext` is
|
||||
// absent — and `!== false` then lands on the plaintext wording, which
|
||||
// asserts that a reader gets the secrets. The envelope may well hold
|
||||
// ciphertext; we simply could not tell. Claiming the worse of two
|
||||
// unknowns is still claiming something we did not establish, which is
|
||||
// the failure this descriptor exists to avoid.
|
||||
if (info.encryptionUnknown !== undefined) {
|
||||
return `The secrets file is mode ${mode}, not 0600, and could not be tightened — others can copy it, and whether that exposes the secrets depends on encryption this build could not determine (${info.encryptionUnknown}).`;
|
||||
}
|
||||
return info.plaintext === false
|
||||
? `The secrets file is mode ${mode}, not 0600, and could not be tightened — others can copy it, and the passphrase is then the only thing protecting its contents.`
|
||||
: `The secrets file is mode ${mode}, not 0600, and could not be tightened — anyone who can read it can read the secrets in it.`;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md)
|
||||
|
||||
#### [Web Client](v2_web_client.md) | [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | [Server](v2_server.md) | Storage
|
||||
#### [Web Client](v2_web_client.md) | [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | [Server](v2_server.md) | Storage
|
||||
|
||||
##### [Overview](v2_storage.md) | Server List File
|
||||
|
||||
## Summary
|
||||
|
|
@ -38,12 +39,12 @@ Replaces the hardcoded `SEED_SERVERS` in `clients/web/src/App.tsx:47` with a fil
|
|||
"filesystem-server-default": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
},
|
||||
"everything-server-default": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-everything"]
|
||||
"args": ["-y", "@modelcontextprotocol/server-everything"],
|
||||
},
|
||||
"acme-api": {
|
||||
"type": "streamable-http",
|
||||
|
|
@ -54,16 +55,16 @@ Replaces the hardcoded `SEED_SERVERS` in `clients/web/src/App.tsx:47` with a fil
|
|||
// for the full contract; the in-memory + wire shape keeps pair-array
|
||||
// headers and flat oauth* fields for the form's controlled-component
|
||||
// editing.
|
||||
"headers": { "X-Tenant": "acme" },
|
||||
"headers": { "X-Tenant": "acme" },
|
||||
"metadata": [{ "key": "trace", "value": "abc" }],
|
||||
"connectionTimeout": 30000,
|
||||
"requestTimeout": 60000,
|
||||
"requestTimeout": 60000,
|
||||
"oauth": {
|
||||
"clientId": "client-abc",
|
||||
"scopes": "read:tools write:tools"
|
||||
}
|
||||
}
|
||||
}
|
||||
"scopes": "read:tools write:tools",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -81,12 +82,12 @@ If the file does not exist when the backend boots, write a file containing the t
|
|||
|
||||
### Reused
|
||||
|
||||
| Concern | File | What we reuse |
|
||||
|---|---|---|
|
||||
| Atomic R/W + ENOENT handling + 0o600 + `mkdir -p` | `core/storage/store-io.ts` | `readStoreFile`, `writeStoreFile`, `deleteStoreFile`, `parseStore`, `serializeStore` |
|
||||
| `mcp.json` parsing + type normalization | `core/mcp/node/config.ts` | `loadMcpServersConfig` (already used by the CLI/TUI runner code); `normalizeServerType` needs to be exported |
|
||||
| Hono backend + auth + storage routes pattern | `core/mcp/remote/node/server.ts` | `/api/storage/:storeId` is the template for the new `/api/servers` routes |
|
||||
| Auth'd fetch from browser | wired via `getAuthToken()` in `clients/web/src/App.tsx:84` | `useServers` will call the backend with `x-mcp-remote-auth: Bearer <token>` |
|
||||
| Concern | File | What we reuse |
|
||||
| ------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| Atomic R/W + ENOENT handling + 0o600 + `mkdir -p` | `core/storage/store-io.ts` | `readStoreFile`, `writeStoreFile`, `deleteStoreFile`, `parseStore`, `serializeStore` |
|
||||
| `mcp.json` parsing + type normalization | `core/mcp/node/config.ts` | `loadMcpServersConfig` (already used by the CLI/TUI runner code); `normalizeServerType` needs to be exported |
|
||||
| Hono backend + auth + storage routes pattern | `core/mcp/remote/node/server.ts` | `/api/storage/:storeId` is the template for the new `/api/servers` routes |
|
||||
| Auth'd fetch from browser | wired via `getAuthToken()` in `clients/web/src/App.tsx:84` | `useServers` will call the backend with `x-mcp-remote-auth: Bearer <token>` |
|
||||
|
||||
### Why not a `{ state, version }` envelope for `mcp.json`
|
||||
|
||||
|
|
@ -123,16 +124,17 @@ Also re-export `normalizeServerType` from `core/mcp/node/config.ts` (or move it
|
|||
|
||||
Add granular endpoints (mirror of `/api/storage/:storeId`, but specialized so the UI can do per-row mutations without read-modify-write across tabs):
|
||||
|
||||
| Method | Path | Body | Response |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/servers` | — | `{ mcpServers: {...} }` — creates the file with seeds if absent |
|
||||
| `POST` | `/api/servers` | `{ id: string, config: MCPServerConfig }` | `{ ok: true }`; 409 if `id` already exists |
|
||||
| `PUT` | `/api/servers/:id` | `{ id?: string, config: MCPServerConfig }` | `{ ok: true }`; supports id rename (rewrites `mcpServers` key; migrates keychain secrets — see §Secret storage) |
|
||||
| `DELETE` | `/api/servers/:id` | — | `{ ok: true }` (ignores missing) |
|
||||
| Method | Path | Body | Response |
|
||||
| -------- | ------------------ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET` | `/api/servers` | — | `{ mcpServers: {...} }` — creates the file with seeds if absent |
|
||||
| `POST` | `/api/servers` | `{ id: string, config: MCPServerConfig }` | `{ ok: true }`; 409 if `id` already exists |
|
||||
| `PUT` | `/api/servers/:id` | `{ id?: string, config: MCPServerConfig }` | `{ ok: true }`; supports id rename (rewrites `mcpServers` key; migrates keychain secrets — see §Secret storage) |
|
||||
| `DELETE` | `/api/servers/:id` | — | `{ ok: true }` (ignores missing) |
|
||||
|
||||
`id` is validated with `validateStoreId` (same alphanum+hyphen+underscore rule as store IDs — prevents anyone slipping `..` into the key). All routes serialize through the same atomic `writeStoreFile`, so concurrent writes are well-defined (last writer wins per-write; granularity reduces blast radius).
|
||||
|
||||
`RemoteServerOptions` gains:
|
||||
|
||||
```ts
|
||||
/** Optional path for the user's server list file. Default: ~/.mcp-inspector/mcp.json */
|
||||
mcpConfigPath?: string;
|
||||
|
|
@ -149,12 +151,16 @@ export interface UseServersResult {
|
|||
error: string | undefined;
|
||||
refresh: () => Promise<void>;
|
||||
addServer: (id: string, config: MCPServerConfig) => Promise<void>;
|
||||
updateServer: (originalId: string, newId: string, config: MCPServerConfig) => Promise<void>;
|
||||
updateServer: (
|
||||
originalId: string,
|
||||
newId: string,
|
||||
config: MCPServerConfig,
|
||||
) => Promise<void>;
|
||||
removeServer: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useServers(opts: {
|
||||
baseUrl: string; // window.location.origin by default in callers
|
||||
baseUrl: string; // window.location.origin by default in callers
|
||||
authToken: string | undefined;
|
||||
}): UseServersResult;
|
||||
```
|
||||
|
|
@ -171,7 +177,7 @@ Fetches on mount via `fetch(`${baseUrl}/api/servers`, ...)` with the auth header
|
|||
|
||||
### UI surfaces
|
||||
|
||||
The `InspectorView` prop interface already declares `onServerAdd` / `onServerEdit` / `onServerClone` / `onServerRemove` / `onServerImportConfig` / `onServerImportJson`. The dialogs themselves are TBD — out of scope for *this* spec is the visual design; in scope is wiring them to the new hook. If the Add/Edit dialog component does not exist yet, ship a minimal Mantine `Modal` + `TextInput` + transport-specific fields. Follow `clients/web/src/components/...` conventions (subcomponent constants via `.withProps()`, theme variants for styling — per `AGENTS.md`'s React rules).
|
||||
The `InspectorView` prop interface already declares `onServerAdd` / `onServerEdit` / `onServerClone` / `onServerRemove` / `onServerImportConfig` / `onServerImportJson`. The dialogs themselves are TBD — out of scope for _this_ spec is the visual design; in scope is wiring them to the new hook. If the Add/Edit dialog component does not exist yet, ship a minimal Mantine `Modal` + `TextInput` + transport-specific fields. Follow `clients/web/src/components/...` conventions (subcomponent constants via `.withProps()`, theme variants for styling — per `AGENTS.md`'s React rules).
|
||||
|
||||
`onServerImportConfig` / `onServerImportJson` map naturally to "paste a full `mcpServers` block" and "upload an `mcp.json` file"; both become bulk `POST /api/servers` calls in a loop (or a single `PUT /api/servers` that we add later). Defer until basic add/edit/remove is working.
|
||||
|
||||
|
|
@ -199,7 +205,7 @@ Per `AGENTS.md`'s "test new or modified code" rule plus the UI-changes guidance:
|
|||
|
||||
## Risks
|
||||
|
||||
- **Concurrent writes from multiple browser tabs.** Granular endpoints reduce the surface (per-row, not whole-file). Same-row contention is last-write-wins, which is fine for a config the user is editing manually. We do *not* add file locking; the cost outweighs the rare case.
|
||||
- **Concurrent writes from multiple browser tabs.** Granular endpoints reduce the surface (per-row, not whole-file). Same-row contention on `mcp.json` is last-write-wins, which is fine for a config the user is editing manually. We do _not_ lock `mcp.json`; the cost outweighs the rare case. The _secrets_ file is a separate question with a separate answer — see [Secret storage](#secret-storage-1356) — because losing a write there loses a credential rather than a preference.
|
||||
- **User edits the file while the browser is open.** Browser holds a stale list until the user hits Refresh on the Servers screen (the `refresh` returned by the hook). Acceptable for v1; auto-watching the file is a possible follow-up but `fs.watch` semantics across OSes are a long tail of bugs.
|
||||
- **Schema drift with Claude Desktop / Cursor.** They occasionally add fields (e.g. Claude Desktop's `disabled`). `loadMcpServersConfig` currently does `JSON.parse(...) as MCPConfig` — extra fields survive the round-trip as long as we don't filter them. The converters in `serverList.ts` should preserve unknown fields on `MCPServerConfig` rather than copying a fixed allow-list.
|
||||
- **Migration from `SEED_SERVERS`.** Existing dev users have no file. First boot writes one — they won't notice. No code path persists the in-memory `useState` list today, so nothing to migrate.
|
||||
|
|
@ -218,17 +224,17 @@ Each server entry may carry these Inspector-extension fields at the top level:
|
|||
"my-server": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer xxx" },
|
||||
"headers": { "Authorization": "Bearer xxx" },
|
||||
"metadata": [{ "key": "tenant", "value": "acme" }],
|
||||
"connectionTimeout": 30000,
|
||||
"requestTimeout": 60000,
|
||||
"requestTimeout": 60000,
|
||||
"oauth": {
|
||||
"clientId": "...",
|
||||
"clientId": "...",
|
||||
"clientSecret": "...",
|
||||
"scopes": "read:tools write:tools"
|
||||
}
|
||||
}
|
||||
}
|
||||
"scopes": "read:tools write:tools",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -242,14 +248,24 @@ Each server entry may carry these Inspector-extension fields at the top level:
|
|||
- `requestTimeout` → `InspectorClientOptions.timeout`.
|
||||
- `connectionTimeout` → `Promise.race` wrapper around `InspectorClient.connect()` in the web client.
|
||||
- `oauth.clientId` / `oauth.clientSecret` / `oauth.scopes` → pre-seeded OAuth client credentials via `InspectorClientOptions.oauth` (the disk-side `oauth` object is lifted into the flat `oauthClientId` / etc. fields on `InspectorServerSettings` for the form).
|
||||
- **First-connect contract**: settings apply on the *first* outbound request after the entry loads from disk — no need to open the settings form. The browser sends `settings` to the backend in the `/api/mcp/connect` body; the backend reads it from `RemoteConnectRequest` and threads it into `createTransportNode`.
|
||||
- **Secret storage (#1356)**: `oauth.clientSecret` and stdio `env` values are persisted in the OS keychain (macOS Keychain Services / Windows Credential Manager / Linux libsecret via `@napi-rs/keyring`), keyed by `(serverId, field)` under the service name `mcp-inspector`. Field names: `oauth-client-secret`, `env:<KEY>` (one per stdio env variable). The on-disk `mcp.json` is stripped of these values — `oauth.clientSecret` is omitted entirely, stdio env keys are preserved with empty-string placeholders (`"env": { "API_KEY": "" }`) so the file still documents the env interface the server expects. The wire shape returned by `GET /api/servers` is unchanged from before #1356: the handler rehydrates values from the keychain so browser code sees the same JSON it has always seen. **TUI/CLI rehydration:** the shared `loadServerEntries()` path (`core/mcp/node/servers.ts`) calls `rehydrateMcpConfigFromKeychain()` (`core/mcp/node/server-secrets.ts`) after reading `mcp.json`, so runner clients see the same effective OAuth client secrets and stdio env values as the web catalog list. This path is read-only — it does not migrate plaintext secrets into the keychain (that remains the web `GET /api/servers` migration sweep). The keychain interactions live in `core/auth/node/secret-store.ts` behind a `SecretStore` interface; `KeyringSecretStore` is the production impl and `InMemorySecretStore` is the test double the integration suite injects via `RemoteServerOptions.secretStore`.
|
||||
- **First-connect contract**: settings apply on the _first_ outbound request after the entry loads from disk — no need to open the settings form. The browser sends `settings` to the backend in the `/api/mcp/connect` body; the backend reads it from `RemoteConnectRequest` and threads it into `createTransportNode`.
|
||||
- **Secret storage (#1356)**: `oauth.clientSecret` and stdio `env` values are persisted in the OS keychain (macOS Keychain Services / Windows Credential Manager / Linux libsecret via `@napi-rs/keyring`), keyed by `(serverId, field)` under the service name `mcp-inspector`. Field names: `oauth-client-secret`, `env:<KEY>` (one per stdio env variable). The on-disk `mcp.json` is stripped of these values — `oauth.clientSecret` is omitted entirely, stdio env keys are preserved with empty-string placeholders (`"env": { "API_KEY": "" }`) so the file still documents the env interface the server expects. The wire shape returned by `GET /api/servers` is unchanged from before #1356: the handler rehydrates values from the keychain so browser code sees the same JSON it has always seen. **TUI/CLI rehydration:** the shared `loadServerEntries()` path (`core/mcp/node/servers.ts`) calls `rehydrateMcpConfigFromKeychain()` (`core/mcp/node/server-secrets.ts`) after reading `mcp.json`, so runner clients see the same effective OAuth client secrets and stdio env values as the web catalog list. This path is read-only — it does not migrate plaintext secrets into the keychain (that remains the web `GET /api/servers` migration sweep). The keychain interactions live in `core/auth/node/secret-store.ts` behind a `SecretStore` interface. As of #1950 there are **three** implementations — `KeyringSecretStore`, `FileSecretStore`, `InMemorySecretStore` — chosen per run by `core/auth/node/secret-store-selection.ts`; the integration suite still injects one directly via `RemoteServerOptions.secretStore`.
|
||||
- **Migration**: on every `GET /api/servers`, the handler walks the freshly-read config and, for any entry that still carries plaintext secrets (older Inspector builds, hand-edited files, files imported from another tool), lifts each value into the keychain and rewrites the file with the stripped shape. The migration is idempotent — when the keychain already holds a value for `(serverId, field)`, the keychain wins and the disk plaintext is dropped unread. After the rewrite the disk file no longer contains the secret material.
|
||||
- **Linux without libsecret**: `KeyringSecretStore` is *tolerant* — only the `set` operation throws `KeychainUnavailableError` (translated to a `503` by the handlers); `get` returns `null` and the destructive operations silently no-op. The result is that no-secret flows (creating a stdio server with no env values, deleting an entry, reading the list, the defensive sweep on POST) all work normally on a minimal Linux box without libsecret. Only the moments where a secret would actually be lost — saving an OAuth client secret, saving a stdio env value, or migrating a plaintext value into the keychain — surface a clear error. macOS and Windows always have a working keychain so this only matters on minimal Linux installs.
|
||||
- **Linux without libsecret**: every `SecretStore` shares one _tolerance contract_ — `get` returns `null`, the destructive operations silently no-op, and only `set` hard-fails (with a `SecretStoreUnavailableError`, translated to a `503`). So no-secret flows (creating a stdio server with no env values, deleting an entry, reading the list, the defensive sweep on POST) work normally on a box with no reachable keychain. **Since #1950 that is no longer the end of the story**: a host without a keychain now falls back to a file or in-memory store rather than being unable to persist a secret at all — see [Secret store selection (#1950)](#secret-store-selection-1950).
|
||||
- **Migration tolerance**: when migration encounters `KeychainUnavailableError`, the GET handler logs a warning, leaves the on-disk plaintext untouched, and serves the (still-plaintext) response. Subsequent reads retry — installing libsecret later lifts the secrets on the next GET without any user action.
|
||||
- **Write ordering on POST/PUT**: keychain writes happen before the disk write, and obsolete-field deletions happen after. The intent is that a `set` failure (the only hard-fail path) leaves both stores in their pre-write state — no half-applied entry on disk that would trap a retry POST at `409`, and no premature deletion of an obsolete field whose disk write later fails.
|
||||
- **Id rename (`PUT` with new `id`)**: secrets are keyed by **server id** (the `mcpServers` map key), not URL. On rename the handler reads existing keychain entries for the **original** id (`expectedSecretFields` + `readKeychainEntriesFor`), merges them with any secrets in the PUT body (body wins on conflict), filters to fields the **new** on-disk entry still expects (so removed stdio env keys are not carried over), writes under the **new** id, updates disk, then `deleteAllForServer(originalId)`. This matters for config-only renames (e.g. Server Config modal) that do not re-send OAuth client secrets or stdio env values — those live only in the keychain after #1356. Covered by `servers-route.test.ts` (`PUT rename moves … when it exists only in the keychain` for OAuth and stdio env).
|
||||
- **Out of scope for this PR**: the OAuth handshake itself still runs in the browser via the MCP SDK, so during the token exchange the secret transits the wire (browser → MCP SDK → OAuth provider's token endpoint). The on-disk win this PR delivers is that the secret is no longer in the shareable / symlinked `mcp.json` and is no longer the source-of-truth on the filesystem. Moving the token exchange to the Node side is tracked separately.
|
||||
- **Secret store selection (#1950)**: #1356 assumed a keychain. On a host without one — the published container (no D-Bus session, #1848), Android/Termux (no prebuilt binary, #1905), a minimal Linux install — `set` hard-failed, so those users could not persist an OAuth client secret or a stdio `env:` value **at all**. #1950 adds the two missing implementations plus the policy that picks one, and the surfaces that say which was picked.
|
||||
- **Selection**: `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright. Otherwise the keychain is _probed_ — an `AsyncEntry` construction plus a real read, because the container that motivated #1848 imports the package fine and only fails when it reaches for a Secret Service that isn't there. A read and not a write, deliberately: probing by writing would deposit a value in the user's login keyring at every startup for a store they may never use. If the probe fails the fallback is **`memory`** in a container whose secrets directory is not on a mount, and **`file`** otherwise — so mounting the volume the README already recommends for the catalog flips the same run to durable storage with no configuration, and an unmounted container gets the honest answer rather than a file `docker run --rm` will discard. Resolution is cached per process so the startup banner, `GET /api/config`, and the store doing the writing cannot disagree.
|
||||
- **`FileSecretStore` at rest**: one JSON document at `~/.mcp-inspector/secrets.json` (or `MCP_INSPECTOR_SECRET_FILE`, else under `MCP_STORAGE_DIR`), written `0600` and re-tightened at startup. With `MCP_INSPECTOR_SECRET_KEY` set it is AES-256-GCM with a scrypt-derived key and a per-write random salt; without it the values are in the clear, and _that_ is what the loud banner and the warning-toned modal footer are about. The **whole map is encrypted as a unit**, not value-by-value, so the account names (`serverId:field`) are hidden too — a per-value scheme would leave a readable index of which servers you hold a client secret for. Upgrading is lazy: setting the passphrase later re-encrypts on the next write rather than rewriting the file during a run that may never touch a secret, and the descriptor reports `pendingEncryption` until it does. A file that can no longer be decrypted reads as empty but **refuses to be written**, because replacing a file of still-valid secrets to satisfy an additive request destroys data.
|
||||
- **Concurrency, and what it does not promise**: within a process, mutations are serialized per **resolved file path** (not per store instance — two `FileSecretStore`s on one file are ordinary, since the resolved store holds one and the keychain hand-off builds another). Across processes there is **no lock**. An earlier iteration had a `mkdir` election with an owner stamp, heartbeat and stale-takeover; three review rounds each found a real race, and the last is not closable with what Node exposes — claiming a stale lock needs compare-and-swap on a directory entry (`renameat2(RENAME_EXCHANGE)`). It was replaced with optimistic concurrency: read `M0`, apply, write `M1`, read back `M2`, and re-apply onto whatever a concurrent writer left if they differ, bounded, with `set` throwing on non-convergence rather than returning as though the value were saved. The comparison is over the **whole map** — checking only your own entry passes in exactly the case that loses data, because yours is present and the other writer's is gone. **This is not mutual exclusion.** The verify only catches a clobber that has already landed, so ordering the two writers write-A / verify-A / write-B / verify-B leaves both reporting success with A's entry gone — A's verify ran before there was anything to see. A crash between write and verify is one instance of that shape, not the whole of it. The window is narrower than the lock's (which lost updates across a wider set of interleavings, with every participant alive) and needs no primitive Node lacks, but it is a real residual and is documented as one. If a deployment needs the guarantee, the answer is an OS-backed lock from a dedicated library, not another hand-rolled election.
|
||||
- **Strict reads (`getStrict`)**: `get` is tolerant by contract, and that tolerance is wrong for exactly one caller — a migration that treats `null` as proof of absence and then _writes_. A transient read failure would look like "nothing there", and the write would replace a newer stored value with an older on-disk copy, inverting the keychain-wins rule the migration is built on. `getStrict` throws instead, and both plaintext migrations plus the keychain hand-off use it. Two traps this hit on the way in, both worth remembering: the seam must be forwarded by `DeferredSecretStore` (the store production actually uses, so without forwarding the strictness existed only in tests that injected a concrete store), and it must be implemented by _every_ store rather than falling back to `get` for the one it was introduced for.
|
||||
- **Bulk reads (`getMany`)**: rehydration asks field by field, and `FileSecretStore.get` reads and decrypts the entire file per call. scrypt at `N=16384` measures ~23ms, so a 20-secret encrypted catalog spent ~450ms of pure key derivation on every `GET /api/servers` — a visible stall rather than a micro-optimization. `getMany` decrypts once per server; stores for which per-field reads are already cheap fall back to parallel `get`.
|
||||
- **Durability gate on migration**: the plaintext-stripping migrations only delete the disk copy once the value is somewhere that outlives the process (`isDurable`). Against a session-scoped store, stripping would trade a secret that survives restarts for one that dies with the process — and it runs on an ordinary `GET`, so merely opening the app would destroy it.
|
||||
- **Hand-off when a keychain appears**: install libsecret after storing secrets in a file, and the next run selects the keychain and stops seeing them — still on disk, read by nothing, nothing visibly broken. `absorbFileSecretsIntoKeyring` copies them over on that run, under the same keychain-wins rule, and deletes the file **only** on complete success. Since the store takes no lock, the delete is conditional: the file is re-read immediately beforehand and compared against what was copied, and the delete is declined when they differ, because another process may have added an entry that was never copied.
|
||||
- **Surfacing it**: the active store rides `GET /api/config` as a `secretStorage` descriptor and is stated in a permanent footer at the bottom of the Client Settings and Server Settings modals — the only two dialogs that accept a secret. A startup banner is seen once by whoever started the process; a toast is seen once; a dismissible banner is by design the thing a user dismisses before doing the work it describes. The descriptor is re-derived per request rather than cached, because `plaintext`/`pendingEncryption` describe bytes this very process changes.
|
||||
|
||||
- **Hard-cutover legacy behavior (per #1358 decision 4)**: files written by the one pre-#1358 build of v2/main have a nested `settings` block. `normalizeMcpServers` drops the node on read and logs a one-line warn including the server id; the persisted headers / metadata / timeouts / OAuth credentials are intentionally lost on first read. Users re-enter them via the settings form (or hand-edit the file into the flat shape). v2 has not shipped a stable release with the nested shape, so the blast radius is the small set of v2/main dogfooders who edited per-server settings between #1353 merging and this change.
|
||||
- **UI**: `ServerSettingsModal` is opened from the server card's settings affordance. Saving routes through `useServers.updateServerSettings(id, settings)` which issues a settings-only `PUT /api/servers/:id` with `{ id, settings }` — the route preserves the on-disk transport config inside its write lock. Conversely, `useServers.updateServer` (driven by the basic-config modal) issues a config-only PUT with `{ id, config }` and the route preserves the on-disk settings fields. Edits in either modal cannot silently wipe the other half.
|
||||
- **Save cadence**: the form fires `onSettingsChange` on every keystroke. `App.tsx` debounces 300 ms and flushes on modal close so a burst of edits coalesces into a single PUT. If the close-flush PUT fails (network hiccup, server 500), a red `@mantine/notifications` toast surfaces the failure — the modal has already closed so a silent failure would leave the user thinking the last edits saved.
|
||||
|
|
|
|||
Loading…
Reference in a new issue