feat(web,core): persist & edit STDIO cwd/env in Server Settings (#1515) (#1518)

* feat(web,core): edit STDIO cwd/env in Server Settings (#1515)

Persist and edit a STDIO server's working directory and environment
variables through the Server Settings modal.

- Add `env` (pair-array, mirrors headers) and `cwd?` to
  `InspectorServerSettings`.
- `storedFieldsToInspectorSettings` lifts the stdio config's `env`/`cwd`
  into settings so the modal can show them; they stay on `config` for the
  transport. Add `envRecordToPairs` / `envPairsToRecord` helpers.
- `env`/`cwd` remain config-owned on disk (the standard mcp.json location):
  the `/api/servers` PUT route writes an edited settings.env/cwd back onto
  the stored stdio config on a settings-only patch (supporting clear), and a
  config-providing PUT keeps config authoritative. `validateSettings`
  accepts `env`/`cwd`.
- Server Settings modal: Working Directory field in Options and a dedicated
  collapsible Environment Variables section — both STDIO-only.
- Registry server.json import sets no `cwd` (the schema has no
  working-directory concept); documented in serverJson.ts.

Tests: serverList converter round-trip, route write-through (set/clear,
config-precedence, non-stdio no-leak, validation), import preservation
(client-config cwd, registry cwd skip), and form/modal component coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): preserve config env/cwd when a settings PUT omits them (#1515 review)

Address Claude's review of #1518.

- `/api/servers` PUT now distinguishes an *absent* `env`/`cwd` (preserve the
  stored value) from an explicit empty list / blank string (clear). Previously
  `validateSettings` coerced a missing `env` to `[]`, so an env-unaware caller
  patching only a timeout would silently wipe the server's env/cwd. The
  integrated web client always resends the full GET-rehydrated env, so it was
  unaffected — this protects the raw HTTP contract.
- `validateSettings` reports `envProvided`/`cwdProvided`; the write-through only
  touches `config.env`/`cwd` for fields the caller actually sent.
- On the omit path, rehydrate the untouched env's keychain values onto the
  rebuilt entry so the secret reconcile re-persists them rather than sweeping
  the blanked-on-disk keys out of the keychain.
- Minor: guard the stdio cast in `mcpConfigToServerEntries` on the transport
  type instead of casting unconditionally.
- Test: settings-only PUT omitting env/cwd preserves config keys + cwd + the
  keychain secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-24 19:15:43 -04:00 • committed by GitHub
commit 5ee347571f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1116 additions and 18 deletions

View file

@ -22,13 +22,14 @@ describe("parseMcpServersConfig", () => {
expect(config.mcpServers.sse.type).toBe("sse");
});
it("preserves extension fields (env, headers) on entries", () => {
it("preserves extension fields (env, cwd, headers) on entries", () => {
const raw = JSON.stringify({
mcpServers: {
s: {
command: "node",
args: ["server.js"],
env: { API_KEY: "abc" },
cwd: "/srv/app",
},
},
});
@ -37,6 +38,7 @@ describe("parseMcpServersConfig", () => {
expect(entry.type).toBe("stdio");
if (entry.type === "stdio") {
expect(entry.env).toEqual({ API_KEY: "abc" });
expect(entry.cwd).toBe("/srv/app");
}
});

View file

@ -64,6 +64,9 @@ describe("parseServerJson — packages", () => {
// surfaced for the user to fill.
if (opt.baseConfig.type === "stdio") {
expect(opt.baseConfig.env).toEqual({ LOG_LEVEL: "info" });
// The registry server.json schema carries no working-directory concept,
// so a parsed package never sets cwd (documented in serverJson.ts).
expect(opt.baseConfig.cwd).toBeUndefined();
}
expect(opt.envVars).toEqual([
{

View file

@ -318,6 +318,7 @@ describe("InspectorClient", () => {
environment: { transport: fakeFactory },
serverSettings: {
headers: [],
env: [],
metadata: [],
connectionTimeout: 50,
requestTimeout: 0,
@ -660,6 +661,7 @@ describe("InspectorClient", () => {
it("getServerSettings returns the constructor settings; setServerSettings replaces them live (#1444)", () => {
const initial = {
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,

View file

@ -213,6 +213,7 @@ describe("Transport", () => {
{ key: "X-Trace", value: "abc123" },
{ key: "", value: "ignored-empty-key" },
],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
@ -267,6 +268,7 @@ describe("Transport", () => {
const config: MCPServerConfig = { type: "sse", url: server.url };
const settings: InspectorServerSettings = {
headers: [{ key: "X-Tenant", value: "acme" }],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
@ -311,6 +313,7 @@ describe("Transport", () => {
};
const settings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,

View file

@ -1233,6 +1233,282 @@ describe("/api/servers routes", () => {
});
});
describe("stdio env / cwd write-through (settings modal)", () => {
it("writes settings.env / settings.cwd onto a stdio config on a settings-only PUT", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { srv: { type: "stdio", command: "node" } },
}),
);
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
env: [{ key: "API_KEY", value: "abc-123" }],
cwd: "/srv/app",
},
}),
});
expect(res.status).toBe(200);
const stored = readConfig(h.configPath).mcpServers.srv as {
env?: Record<string, string>;
cwd?: string;
};
// env key persisted on the config (value stripped to keychain), cwd set.
expect(stored.env).toEqual({ API_KEY: "" });
expect(stored.cwd).toBe("/srv/app");
expect(await h.secretStore.get("srv", envSecretField("API_KEY"))).toBe(
"abc-123",
);
// env / cwd are NOT persisted as a settings wrapper — they live on config.
expect(stored).not.toHaveProperty("settings");
});
it("preserves config.env / cwd and the keychain when a settings-only PUT omits them", async () => {
// An env-unaware caller patching just a timeout must not wipe the stored
// env/cwd. Absent env/cwd on the wire => preserve, distinct from explicit
// empty => clear.
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
srv: {
type: "stdio",
command: "node",
env: { API_KEY: "" },
cwd: "/srv/app",
},
},
}),
);
await h.secretStore.set("srv", envSecretField("API_KEY"), "abc-123");
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
// env / cwd intentionally omitted; only a timeout is patched.
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
connectionTimeout: 5000,
requestTimeout: 0,
},
}),
});
expect(res.status).toBe(200);
const stored = readConfig(h.configPath).mcpServers.srv as {
env?: Record<string, string>;
cwd?: string;
};
// Disk keys + cwd preserved (env value blanked, as always — it lives in
// the keychain).
expect(stored.env).toEqual({ API_KEY: "" });
expect(stored.cwd).toBe("/srv/app");
// Keychain value survives: the omit path rehydrates it so the reconcile
// doesn't sweep the untouched secret.
expect(await h.secretStore.get("srv", envSecretField("API_KEY"))).toBe(
"abc-123",
);
});
it("GET lifts the stored env / cwd back into the wire config (round-trip)", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
srv: {
type: "stdio",
command: "node",
env: { API_KEY: "" },
cwd: "/srv/app",
},
},
}),
);
await h.secretStore.set("srv", envSecretField("API_KEY"), "abc-123");
const res = await fetch(`${h.baseUrl}/api/servers`);
const body = (await res.json()) as MCPConfig;
const srv = body.mcpServers.srv as {
env?: Record<string, string>;
cwd?: string;
};
expect(srv.env).toEqual({ API_KEY: "abc-123" });
expect(srv.cwd).toBe("/srv/app");
});
it("clears env / cwd from the config when the settings-only PUT sends an empty list / blank cwd", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
srv: {
type: "stdio",
command: "node",
env: { API_KEY: "" },
cwd: "/srv/app",
},
},
}),
);
await h.secretStore.set("srv", envSecretField("API_KEY"), "abc-123");
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
env: [],
cwd: "",
},
}),
});
expect(res.status).toBe(200);
const stored = readConfig(h.configPath).mcpServers
.srv as unknown as Record<string, unknown>;
expect(stored).not.toHaveProperty("env");
expect(stored).not.toHaveProperty("cwd");
// The removed env key reconciles out of the keychain.
expect(await h.secretStore.get("srv", envSecretField("API_KEY"))).toBe(
null,
);
});
it("drops empty-key env rows on a settings-only PUT", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { srv: { type: "stdio", command: "node" } },
}),
);
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
env: [
{ key: "KEEP", value: "1" },
{ key: "", value: "drop-me" },
{ key: " ", value: "drop-me-too" },
],
},
}),
});
expect(res.status).toBe(200);
const stored = readConfig(h.configPath).mcpServers.srv as {
env?: Record<string, string>;
};
expect(Object.keys(stored.env ?? {})).toEqual(["KEEP"]);
});
it("a config-providing PUT owns env/cwd; the preserved settings mirror does not revert it", async () => {
// Pre-seed a stdio server with env A=1 (keychain-backed).
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
srv: {
type: "stdio",
command: "node",
env: { A: "" },
cwd: "/old",
},
},
}),
);
await h.secretStore.set("srv", envSecretField("A"), "1");
// Config-only PUT (settings omitted → preserved). The new config's env/cwd
// must win even though the preserved settings mirror still carries the old.
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: {
type: "stdio",
command: "node",
env: { A: "2" },
cwd: "/new",
},
}),
});
expect(res.status).toBe(200);
const stored = readConfig(h.configPath).mcpServers.srv as {
env?: Record<string, string>;
cwd?: string;
};
expect(stored.cwd).toBe("/new");
expect(stored.env).toEqual({ A: "" });
expect(await h.secretStore.get("srv", envSecretField("A"))).toBe("2");
});
it("rejects a non-array settings.env with 400", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: { srv: { type: "stdio", command: "node" } },
}),
);
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
env: "nope",
},
}),
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error?: string };
expect(body.error).toMatch(/settings\.env/);
});
it("ignores settings env/cwd for a non-stdio server (no leak onto config)", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
srv: { type: "streamable-http", url: "https://x.test/mcp" },
},
}),
);
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settings: {
headers: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
env: [{ key: "API_KEY", value: "abc" }],
cwd: "/srv/app",
},
}),
});
expect(res.status).toBe(200);
const stored = readConfig(h.configPath).mcpServers
.srv as unknown as Record<string, unknown>;
expect(stored).not.toHaveProperty("env");
expect(stored).not.toHaveProperty("cwd");
});
});
describe("concurrent mutations", () => {
it("does not lose updates when many adds fire in parallel (write-lock)", async () => {
// Without the in-process mutex, concurrent POSTs would all read the

View file

@ -407,6 +407,7 @@ describe("Remote transport e2e", () => {
{ key: "X-Tenant", value: "acme" },
{ key: "X-Trace", value: "abc123" },
],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,