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.
|
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.
|
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");
|
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", () => {
|
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
|
// A guessed answer under a secret field is worse than no answer, so the
|
||||||
// unknown case is silence rather than a default.
|
// 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
|
// secrets. Claiming otherwise overstates the exposure, and a warning
|
||||||
// that cries wolf is discounted on the occasion it is literally true.
|
// that cries wolf is discounted on the occasion it is literally true.
|
||||||
parts.push(
|
parts.push(
|
||||||
info.plaintext === false
|
// Three cases, mirroring `secretStorageCaveat`. With
|
||||||
? `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.`
|
// `encryptionUnknown` set, `plaintext` is absent and `!== false` would
|
||||||
: `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`,
|
// 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) {
|
} else if (info.permissionsUnknown !== undefined) {
|
||||||
parts.push(
|
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", () => {
|
it("warns about a loose mode even on an encrypted file", () => {
|
||||||
// The passphrase is then the only thing between a reader and the values,
|
// The passphrase is then the only thing between a reader and the values,
|
||||||
// which is worth saying rather than rendering the quiet neutral state.
|
// 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",
|
"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) => {
|
])("reports unreadable for %s", async (_label, envelope, detail) => {
|
||||||
await fs.writeFile(filePath(), JSON.stringify(envelope), "utf-8");
|
await fs.writeFile(filePath(), JSON.stringify(envelope), "utf-8");
|
||||||
const store = new FileSecretStore({ filePath: filePath() });
|
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)", () => {
|
describe("concurrent writers (no lock, optimistic verify-and-retry)", () => {
|
||||||
// The lock these replace was removed after three review rounds each found
|
// 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
|
// 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
|
// queue and writing through one from inside a queued section would
|
||||||
// deadlock on it.
|
// deadlock on it.
|
||||||
const clobberAfterEveryWrite = (store: FileSecretStore): void => {
|
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 {
|
const inner = store as unknown as {
|
||||||
writeMap: (m: Record<string, string>) => Promise<void>;
|
writeMap: (m: Record<string, string>) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,8 @@ const FORMAT_VERSION = 1;
|
||||||
const CIPHER = "aes-256-gcm";
|
const CIPHER = "aes-256-gcm";
|
||||||
const KEY_BYTES = 32;
|
const KEY_BYTES = 32;
|
||||||
const IV_BYTES = 12;
|
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
|
/** 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
|
* 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. */
|
* accompanies and is derived once per read/write rather than per secret. */
|
||||||
|
|
@ -235,6 +237,48 @@ export type SecretFileEncryptionState =
|
||||||
| { state: "encrypted" }
|
| { state: "encrypted" }
|
||||||
| { state: "unreadable"; detail: string };
|
| { 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 {
|
export interface FileSecretStoreOptions {
|
||||||
/** Absolute path of the secrets file. */
|
/** Absolute path of the secrets file. */
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
|
@ -313,23 +357,14 @@ export class FileSecretStore implements SecretStore {
|
||||||
}
|
}
|
||||||
if (parsed.encryption === "none") return { state: "plaintext" };
|
if (parsed.encryption === "none") return { state: "plaintext" };
|
||||||
if (parsed.encryption === CIPHER) {
|
if (parsed.encryption === CIPHER) {
|
||||||
// Naming the cipher is not the same as being openable. An envelope
|
// Naming the cipher is not the same as being openable, and a partial
|
||||||
// with no `kdf`/`data` — or a `data` that is not the three-part
|
// check is its own trap: three dot-separated strings satisfy
|
||||||
// payload — is refused by `readMap`, so reporting it as "encrypted"
|
// `decodeParts` while an empty tag, a 4-byte IV or `N: 3` still make
|
||||||
// gives the quiet, reassuring footer for a file whose very next save
|
// every decrypt throw. Reporting "encrypted" then gives the quiet,
|
||||||
// is guaranteed to fail. Check the shape the decryptor requires.
|
// reassuring footer to a file whose very next save is guaranteed to
|
||||||
if (!parsed.kdf || typeof parsed.data !== "string") {
|
// fail, which is the one thing this state exists to prevent.
|
||||||
return {
|
const problem = encryptedEnvelopeProblem(parsed);
|
||||||
state: "unreadable",
|
if (problem) return { state: "unreadable", detail: problem };
|
||||||
detail: "encrypted envelope is missing its kdf or data",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!decodeParts(parsed.data)) {
|
|
||||||
return {
|
|
||||||
state: "unreadable",
|
|
||||||
detail: "encrypted payload is not iv.tag.ciphertext",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { state: "encrypted" };
|
return { state: "encrypted" };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -509,23 +544,32 @@ export class FileSecretStore implements SecretStore {
|
||||||
* someone wrote between our write and our read — so re-apply onto what
|
* someone wrote between our write and our read — so re-apply onto what
|
||||||
* they left and try again.
|
* they left and try again.
|
||||||
*
|
*
|
||||||
* The comparison is over the **whole map**, not just the entry we touched,
|
* 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
|
* Checking only our own key would pass in precisely the case that loses
|
||||||
* passes in precisely the case that loses data: our entry is present, and
|
* data: our entry is present, and it is the *other* writer's that is gone.
|
||||||
* it is the *other* writer's that is gone. Trace two writers A and B from
|
* When the verify does fire, the writer that was clobbered is the one that
|
||||||
* the same `M0` — A writes `MA`, B writes `MB` and clobbers A's entry. B
|
* repairs it — A writes `MA`, B writes `MB` over it, B verifies `MB`
|
||||||
* verifies and sees `M2 === MB`, correctly: B did nothing wrong. A
|
* correctly, A verifies, sees `M2 !== MA`, re-applies onto `MB`.
|
||||||
* 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 residual window, stated plainly rather than left to be
|
* **This is not mutual exclusion, and the gap is wider than a crash.**
|
||||||
* rediscovered:** a process that crashes between its write and its
|
* The verify only catches a clobber that has already landed. Order the
|
||||||
* verifying read cannot notice it was clobbered, and nothing else will.
|
* same two writers as write-A, verify-A, write-B, verify-B and both
|
||||||
* That is strictly narrower than the lock's failure — which lost updates
|
* succeed while A's entry is gone: A's verify ran before B's write, so
|
||||||
* with every participant alive and well — and unlike the lock it needs no
|
* there was nothing yet to see, and B did nothing wrong. Nothing detects
|
||||||
* primitive Node lacks. It is not mutual exclusion and this file does not
|
* it afterwards. A crash between write and verify is one instance of the
|
||||||
* claim to provide it.
|
* 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
|
* Reads deliberately do not participate: `writeStoreFile` is atomic
|
||||||
* (write-temp-then-rename), so a reader sees either the old file or the
|
* (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
|
// kind of wrong: a reader of an *encrypted* file gets ciphertext, not
|
||||||
// secrets. Saying otherwise trains people to discount the warning on the
|
// secrets. Saying otherwise trains people to discount the warning on the
|
||||||
// occasion it is literally true.
|
// 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
|
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 — 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.`;
|
: `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)
|
### [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
|
##### [Overview](v2_storage.md) | Server List File
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
@ -38,12 +39,12 @@ Replaces the hardcoded `SEED_SERVERS` in `clients/web/src/App.tsx:47` with a fil
|
||||||
"filesystem-server-default": {
|
"filesystem-server-default": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||||
},
|
},
|
||||||
"everything-server-default": {
|
"everything-server-default": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "@modelcontextprotocol/server-everything"]
|
"args": ["-y", "@modelcontextprotocol/server-everything"],
|
||||||
},
|
},
|
||||||
"acme-api": {
|
"acme-api": {
|
||||||
"type": "streamable-http",
|
"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
|
// for the full contract; the in-memory + wire shape keeps pair-array
|
||||||
// headers and flat oauth* fields for the form's controlled-component
|
// headers and flat oauth* fields for the form's controlled-component
|
||||||
// editing.
|
// editing.
|
||||||
"headers": { "X-Tenant": "acme" },
|
"headers": { "X-Tenant": "acme" },
|
||||||
"metadata": [{ "key": "trace", "value": "abc" }],
|
"metadata": [{ "key": "trace", "value": "abc" }],
|
||||||
"connectionTimeout": 30000,
|
"connectionTimeout": 30000,
|
||||||
"requestTimeout": 60000,
|
"requestTimeout": 60000,
|
||||||
"oauth": {
|
"oauth": {
|
||||||
"clientId": "client-abc",
|
"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
|
### Reused
|
||||||
|
|
||||||
| Concern | File | What we reuse |
|
| Concern | File | What we reuse |
|
||||||
|---|---|---|
|
| ------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
| Atomic R/W + ENOENT handling + 0o600 + `mkdir -p` | `core/storage/store-io.ts` | `readStoreFile`, `writeStoreFile`, `deleteStoreFile`, `parseStore`, `serializeStore` |
|
| 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 |
|
| `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 |
|
| 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>` |
|
| 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`
|
### 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):
|
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 |
|
| Method | Path | Body | Response |
|
||||||
|---|---|---|---|
|
| -------- | ------------------ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||||
| `GET` | `/api/servers` | — | `{ mcpServers: {...} }` — creates the file with seeds if absent |
|
| `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 |
|
| `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) |
|
| `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) |
|
| `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).
|
`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:
|
`RemoteServerOptions` gains:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
/** Optional path for the user's server list file. Default: ~/.mcp-inspector/mcp.json */
|
/** Optional path for the user's server list file. Default: ~/.mcp-inspector/mcp.json */
|
||||||
mcpConfigPath?: string;
|
mcpConfigPath?: string;
|
||||||
|
|
@ -149,12 +151,16 @@ export interface UseServersResult {
|
||||||
error: string | undefined;
|
error: string | undefined;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
addServer: (id: string, config: MCPServerConfig) => 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>;
|
removeServer: (id: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useServers(opts: {
|
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;
|
authToken: string | undefined;
|
||||||
}): UseServersResult;
|
}): UseServersResult;
|
||||||
```
|
```
|
||||||
|
|
@ -171,7 +177,7 @@ Fetches on mount via `fetch(`${baseUrl}/api/servers`, ...)` with the auth header
|
||||||
|
|
||||||
### UI surfaces
|
### 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.
|
`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
|
## 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.
|
- **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.
|
- **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.
|
- **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": {
|
"my-server": {
|
||||||
"type": "streamable-http",
|
"type": "streamable-http",
|
||||||
"url": "https://example.com/mcp",
|
"url": "https://example.com/mcp",
|
||||||
"headers": { "Authorization": "Bearer xxx" },
|
"headers": { "Authorization": "Bearer xxx" },
|
||||||
"metadata": [{ "key": "tenant", "value": "acme" }],
|
"metadata": [{ "key": "tenant", "value": "acme" }],
|
||||||
"connectionTimeout": 30000,
|
"connectionTimeout": 30000,
|
||||||
"requestTimeout": 60000,
|
"requestTimeout": 60000,
|
||||||
"oauth": {
|
"oauth": {
|
||||||
"clientId": "...",
|
"clientId": "...",
|
||||||
"clientSecret": "...",
|
"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`.
|
- `requestTimeout` → `InspectorClientOptions.timeout`.
|
||||||
- `connectionTimeout` → `Promise.race` wrapper around `InspectorClient.connect()` in the web client.
|
- `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).
|
- `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`.
|
- **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`.
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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