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
7 changed files with 1116 additions and 18 deletions

View file

@ -338,6 +338,7 @@ describe("RemoteClientTransport", () => {
});
const settings = {
headers: [{ key: "X-Tenant", value: "acme" }],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,

View file

@ -2,13 +2,17 @@ import { describe, it, expect } from "vitest";
import {
cleanRoots,
DEFAULT_SEED_CONFIG,
envPairsToRecord,
envRecordToPairs,
expectedSecretFields,
extractSecretsFromStored,
inspectorSettingsToStoredFields,
mcpConfigToServerEntries,
mergeSecretsIntoStored,
normalizeServerType,
serverEntriesToMcpConfig,
serializeMcpConfig,
storedFieldsToInspectorSettings,
} from "@inspector/core/mcp/serverList.js";
import {
SECRET_FIELD_OAUTH_CLIENT_SECRET,
@ -314,6 +318,8 @@ describe("serverEntriesToMcpConfig", () => {
expect(entry?.settings).toEqual({
// Object headers on disk → pair-array headers in memory
headers: [{ key: "X-Tenant", value: "acme" }],
// Non-stdio server → empty env mirror in memory (for the form)
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
@ -393,6 +399,7 @@ describe("serverEntriesToMcpConfig", () => {
{ key: "", value: "stub" },
{ key: " ", value: "whitespace" },
],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
@ -419,6 +426,7 @@ describe("serverEntriesToMcpConfig", () => {
config: { type: "streamable-http", url: "https://x.test" },
settings: {
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
@ -447,6 +455,7 @@ describe("serverEntriesToMcpConfig", () => {
config: { type: "stdio", command: "node" },
settings: {
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
@ -485,6 +494,7 @@ describe("serverEntriesToMcpConfig", () => {
config: { type: "stdio", command: "node" },
settings: {
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
@ -511,6 +521,7 @@ describe("serverEntriesToMcpConfig", () => {
config: { type: "stdio", command: "node" },
settings: {
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
@ -777,3 +788,130 @@ describe("expectedSecretFields", () => {
).toEqual([SECRET_FIELD_OAUTH_CLIENT_SECRET]);
});
});
describe("envRecordToPairs / envPairsToRecord", () => {
it("envRecordToPairs preserves key insertion order", () => {
expect(envRecordToPairs({ B: "2", A: "1" })).toEqual([
{ key: "B", value: "2" },
{ key: "A", value: "1" },
]);
});
it("envRecordToPairs returns an empty array for undefined", () => {
expect(envRecordToPairs(undefined)).toEqual([]);
});
it("envPairsToRecord drops empty / whitespace-only keys", () => {
expect(
envPairsToRecord([
{ key: "API_KEY", value: "secret" },
{ key: "", value: "ignored" },
{ key: " ", value: "ignored" },
{ key: "DEBUG", value: "1" },
]),
).toEqual({ API_KEY: "secret", DEBUG: "1" });
});
it("round-trips a populated env through both converters", () => {
const record = { API_KEY: "secret", DEBUG: "1" };
expect(envPairsToRecord(envRecordToPairs(record))).toEqual(record);
});
});
describe("stdio env / cwd mirroring", () => {
it("lifts stdio env (object → pair-array) and cwd onto settings while keeping them on config", () => {
const cfg: MCPConfig = {
mcpServers: {
alpha: {
type: "stdio",
command: "node",
args: ["server.js"],
env: { API_KEY: "secret", DEBUG: "1" },
cwd: "/srv/app",
},
},
};
const [entry] = mcpConfigToServerEntries(cfg);
expect(entry?.settings?.env).toEqual([
{ key: "API_KEY", value: "secret" },
{ key: "DEBUG", value: "1" },
]);
expect(entry?.settings?.cwd).toBe("/srv/app");
// env / cwd remain on the SDK config so the transport still sees them.
expect(entry?.config).toMatchObject({
env: { API_KEY: "secret", DEBUG: "1" },
cwd: "/srv/app",
});
});
it("materializes a settings node for a bare stdio entry carrying only env", () => {
const cfg: MCPConfig = {
mcpServers: {
alpha: { type: "stdio", command: "node", env: { A: "1" } },
},
};
const [entry] = mcpConfigToServerEntries(cfg);
expect(entry?.settings?.env).toEqual([{ key: "A", value: "1" }]);
});
it("leaves env an empty list and cwd unset for a stdio entry without them", () => {
const settings = storedFieldsToInspectorSettings({
headers: { "X-A": "b" },
});
expect(settings?.env).toEqual([]);
expect(settings?.cwd).toBeUndefined();
});
it("mirrors empty env for non-stdio servers", () => {
const cfg: MCPConfig = {
mcpServers: {
alpha: {
type: "streamable-http",
url: "https://x.test/mcp",
headers: { "X-Tenant": "acme" },
},
},
};
const [entry] = mcpConfigToServerEntries(cfg);
expect(entry?.settings?.env).toEqual([]);
});
it("does NOT re-emit env / cwd from inspectorSettingsToStoredFields (config owns them on disk)", () => {
// env / cwd are config fields; the write side is the PUT route's
// write-through, not this converter. So even a populated settings.env must
// not leak back as a settings delta that would clobber config on merge.
const out = inspectorSettingsToStoredFields({
headers: [],
env: [{ key: "API_KEY", value: "secret" }],
cwd: "/srv/app",
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 1000,
roots: [],
});
expect(out).not.toHaveProperty("env");
expect(out).not.toHaveProperty("cwd");
});
it("preserves stdio env / cwd through a config serialize round-trip", () => {
const cfg: MCPConfig = {
mcpServers: {
alpha: {
type: "stdio",
command: "node",
env: { API_KEY: "secret" },
cwd: "/srv/app",
},
},
};
const entries = mcpConfigToServerEntries(cfg);
const back = serverEntriesToMcpConfig(entries);
expect(back.mcpServers.alpha).toMatchObject({
command: "node",
env: { API_KEY: "secret" },
cwd: "/srv/app",
});
});
});

View file

@ -11,6 +11,7 @@ function prompt(name: string): Prompt {
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,

View file

@ -11,6 +11,7 @@ function template(name: string): ResourceTemplate {
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,

View file

@ -11,6 +11,7 @@ function resource(uri: string): Resource {
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,

View file

@ -11,6 +11,7 @@ function tool(name: string): Tool {
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,

View file

@ -411,6 +411,7 @@ describe("useServers", () => {
await act(async () => {
await result.current.updateServerSettings("alpha", {
headers: [{ key: "X-Tenant", value: "acme" }],
env: [],
metadata: [{ key: "trace", value: "abc" }],
connectionTimeout: 5000,
requestTimeout: 30000,
@ -423,6 +424,7 @@ describe("useServers", () => {
await waitFor(() => {
expect(result.current.servers[0]?.settings).toEqual({
headers: [{ key: "X-Tenant", value: "acme" }],
env: [],
metadata: [{ key: "trace", value: "abc" }],
connectionTimeout: 5000,
requestTimeout: 30000,
@ -457,6 +459,7 @@ describe("useServers", () => {
act(async () => {
await result.current.updateServerSettings("nonexistent", {
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
@ -506,6 +509,7 @@ describe("useServers", () => {
});
expect(result.current.servers[0]?.settings).toEqual({
headers: [{ key: "X-Keep", value: "yes" }],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,