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

View file

@ -228,6 +228,12 @@ function parsePackage(raw: Record<string, unknown>): ServerJsonOption | null {
? [...runtimeArgs, "-y", runtime.ref, ...packageArgs]
: [...runtimeArgs, runtime.ref, ...packageArgs];
// No `cwd`: the registry `server.json` package schema (2025-12-11) carries no
// working-directory concept — its package fields are registryType,
// registryBaseUrl, identifier, version, fileSha256, runtimeHint,
// runtimeArguments, packageArguments, environmentVariables, transport. So
// there is nothing to map onto `config.cwd` here; the user can set one
// afterward via the Add/Edit or Server Settings modal.
const baseConfig: StdioServerConfig = {
type: "stdio",
command: runtime.command,

View file

@ -42,6 +42,7 @@ export function headersToServerSettings(
}
return {
headers: Object.entries(headers).map(([key, value]) => ({ key, value })),
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,

View file

@ -35,10 +35,12 @@ import type {
InspectorServerSettings,
MCPConfig,
MCPServerConfig,
StdioServerConfig,
StoredMCPServer,
} from "../../types.js";
import {
DEFAULT_SEED_CONFIG,
envPairsToRecord,
expectedSecretFields,
extractSecretsFromStored,
INSPECTOR_FIELD_KEYS,
@ -57,6 +59,7 @@ import {
KeyringSecretStore,
type SecretStore,
} from "../../../auth/node/secret-store.js";
import { envSecretField } from "../../../auth/secret-fields.js";
/**
* Shape of the initial config returned by GET /api/config (defaults for client).
@ -1100,6 +1103,47 @@ export function createRemoteApp(
return normalized;
};
// Write-through for the stdio `env` / `cwd` config fields edited via the
// Server Settings modal. They are NOT Inspector-extension fields (the
// transport reads them off `config`), so `inspectorSettingsToStoredFields`
// doesn't emit them. Instead, on a settings-only patch the settings mirror is
// authoritative: this maps `settings.env` / `settings.cwd` back onto the
// stored stdio config, including clearing (empty list / blank cwd → field
// removed) so a user can delete a value through the modal.
//
// Each field is only written when the caller actually *sent* it (`provided`).
// An absent field on the wire means "leave the stored value alone" — without
// this gate an `env`-unaware client doing a settings patch (e.g. just bumping
// a timeout) would silently wipe the server's `env`/`cwd`, since
// `validateSettings` coerces a missing `env` to `[]` (which otherwise reads as
// "clear"). The integrated web client always resends the full GET-rehydrated
// `env`, so it is unaffected either way; this protects the raw HTTP contract.
// No-op for non-stdio entries. Mutates `entry` in place.
const applyStdioSettingsToConfig = (
entry: StoredMCPServer,
settings: InspectorServerSettings,
provided: { env: boolean; cwd: boolean },
): void => {
if (!(entry.type === "stdio" || entry.type === undefined)) return;
const stdio = entry as StdioServerConfig & StoredMCPServer;
if (provided.env) {
const env = envPairsToRecord(settings.env);
if (Object.keys(env).length > 0) {
stdio.env = env;
} else {
delete stdio.env;
}
}
if (provided.cwd) {
const cwd = settings.cwd?.trim();
if (cwd) {
stdio.cwd = cwd;
} else {
delete stdio.cwd;
}
}
};
// Structurally validates an InspectorServerSettings payload off the wire so
// a malformed body can't persist to disk and crash the UI later (e.g.
// `settings: []` or `settings: { headers: "oops" }`). Mirrors the lenient
@ -1108,7 +1152,16 @@ export function createRemoteApp(
const validateSettings = (
raw: unknown,
):
| { ok: true; value: InspectorServerSettings }
| {
ok: true;
value: InspectorServerSettings;
// Whether the caller actually sent `env` / `cwd` (vs. them being absent
// and defaulted). The write-through uses this to preserve a stored
// `config.env`/`cwd` on a patch that omits the field, rather than
// treating "absent" as "clear".
envProvided: boolean;
cwdProvided: boolean;
}
| { ok: false; error: string } => {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, error: "settings must be an object" };
@ -1136,6 +1189,19 @@ export function createRemoteApp(
error: "settings.metadata must be an array of { key, value }",
};
}
// env is optional on the wire (older clients / non-stdio servers won't send
// it); when present it must be an array of { key, value } like headers.
if (obj.env !== undefined && !isKvArray(obj.env)) {
return {
ok: false,
error: "settings.env must be an array of { key, value }",
};
}
// cwd is optional; when present it must be a string. Empty coerces to absent
// below (matching the read side), which the write-through reads as "clear".
if (obj.cwd !== undefined && typeof obj.cwd !== "string") {
return { ok: false, error: "settings.cwd must be a string" };
}
if (
typeof obj.connectionTimeout !== "number" ||
obj.connectionTimeout < 0
@ -1213,6 +1279,9 @@ export function createRemoteApp(
// later be misread as "OAuth configured."
const value: InspectorServerSettings = {
headers: obj.headers as { key: string; value: string }[],
// Absent → empty list, matching the read side. The write-through drops
// empty-key rows and clears `config.env` when the list is empty.
env: isKvArray(obj.env) ? obj.env : [],
metadata: obj.metadata as { key: string; value: string }[],
connectionTimeout: obj.connectionTimeout as number,
requestTimeout: obj.requestTimeout as number,
@ -1250,7 +1319,18 @@ export function createRemoteApp(
if (typeof obj.oauthScopes === "string" && obj.oauthScopes !== "") {
value.oauthScopes = obj.oauthScopes;
}
return { ok: true, value };
// Empty cwd coerces to absent on the value (matching the read side); the
// write-through distinguishes "sent cwd: '' " (clear) from "cwd omitted"
// (preserve) via `cwdProvided` below rather than the value alone.
if (typeof obj.cwd === "string" && obj.cwd !== "") {
value.cwd = obj.cwd;
}
return {
ok: true,
value,
envProvided: obj.env !== undefined,
cwdProvided: obj.cwd !== undefined,
};
};
// In-process serialization for the read-modify-write flow on the
@ -1747,7 +1827,12 @@ export function createRemoteApp(
type SettingsIntent =
| { kind: "preserve" }
| { kind: "clear" }
| { kind: "apply"; value: InspectorServerSettings };
| {
kind: "apply";
value: InspectorServerSettings;
envProvided: boolean;
cwdProvided: boolean;
};
let settingsIntent: SettingsIntent;
if (body.settings === undefined) {
settingsIntent = { kind: "preserve" };
@ -1756,7 +1841,12 @@ export function createRemoteApp(
} else {
const validated = validateSettings(body.settings);
if (!validated.ok) return c.json({ error: validated.error }, 400);
settingsIntent = { kind: "apply", value: validated.value };
settingsIntent = {
kind: "apply",
value: validated.value,
envProvided: validated.envProvided,
cwdProvided: validated.cwdProvided,
};
}
const newId = typeof body.id === "string" ? body.id : originalId;
if (!validateStoreId(newId)) {
@ -1819,6 +1909,48 @@ export function createRemoteApp(
// key dropped, OAuth secret cleared) gets cleaned up rather
// than orphaned.
const built = buildStoredEntry(newId, nextConfig, nextSettings);
// stdio env/cwd are config fields editable from both the Add/Edit modal
// (patches `config`) and the Server Settings modal (patches `settings`).
// On a settings-only *apply* (config preserved) the settings mirror is
// authoritative for the fields the caller actually sent — write those
// through onto the stored config so edits and explicit clears take
// effect, while a field the caller omitted leaves the stored value
// untouched. When a config body was provided it owns env/cwd and the
// settings mirror is ignored; a preserve/clear settings intent never
// touches config env/cwd. Runs before secret extraction so a removed env
// key reconciles out of the keychain.
if (body.config === undefined && settingsIntent.kind === "apply") {
applyStdioSettingsToConfig(built, settingsIntent.value, {
env: settingsIntent.envProvided,
cwd: settingsIntent.cwdProvided,
});
// When the caller omitted `env`, the stored env is preserved
// structurally but its values are blanked on disk (the real values
// live in the keychain). Rehydrate those onto `built` so the secret
// extraction + reconcile below re-persist them, instead of treating
// the blanked keys as "removed" and sweeping them from the keychain.
// The integrated web client always resends the full GET-rehydrated
// env, so this only matters for env-unaware raw HTTP callers.
if (
!settingsIntent.envProvided &&
(built.type === "stdio" || built.type === undefined)
) {
const stdio = built as StdioServerConfig & StoredMCPServer;
if (stdio.env && Object.keys(stdio.env).length > 0) {
const keys = Object.keys(stdio.env);
const existingSecrets = await readKeychainEntriesFor(
originalId,
keys.map((k) => envSecretField(k)),
);
const rehydrated: Record<string, string> = { ...stdio.env };
for (const k of keys) {
const v = existingSecrets[envSecretField(k)];
if (v !== undefined) rehydrated[k] = v;
}
stdio.env = rehydrated;
}
}
}
const { stripped, secrets } = extractSecretsFromStored(built);
const next: MCPConfig = { mcpServers: {} };
for (const [key, val] of Object.entries(current.mcpServers)) {

View file

@ -98,6 +98,36 @@ type StoredInspectorFields = Pick<
| "roots"
>;
/**
* Convert a stored stdio `env` record into the controlled key/value rows the
* settings form edits, preserving the object's key insertion order. Empty when
* absent. Inverse of `envPairsToRecord`.
*/
export function envRecordToPairs(
env: Record<string, string> | undefined,
): { key: string; value: string }[] {
return env
? Object.entries(env).map(([key, value]) => ({ key, value }))
: [];
}
/**
* Collapse the form's controlled `env` rows back into a `Record`, dropping rows
* with an empty/whitespace key (the form lets users leave a new row blank
* mid-edit). Inverse of `envRecordToPairs`. Used by the `/api/servers` PUT
* write-through that maps `settings.env` back onto `config.env`.
*/
export function envPairsToRecord(
pairs: { key: string; value: string }[],
): Record<string, string> {
const out: Record<string, string> = {};
for (const { key, value } of pairs) {
if (key.trim() === "") continue;
out[key] = value;
}
return out;
}
/**
* Lift the Inspector-extension fields off a freshly-read `StoredMCPServer`
* into the pair-array / flat-OAuth `InspectorServerSettings` shape the form
@ -109,9 +139,20 @@ type StoredInspectorFields = Pick<
* `oauth.*` becomes the flat `oauthClientId` / `oauthClientSecret` /
* `oauthScopes` fields. Numeric timeouts default to 0 when absent — the form
* needs concrete values to render and 0 is the SDK's "no timeout" signal.
*
* `env` / `cwd` are SDK config fields (not Inspector-extension keys), but they
* are mirrored into the settings here so the Server Settings modal can edit
* them for stdio servers. They are NOT re-emitted by
* `inspectorSettingsToStoredFields` — the write side lives in the PUT route's
* write-through (config stays the single on-disk owner). Their presence alone
* is enough to materialize a settings node so a bare `{ command, env }` entry
* surfaces its env in the form.
*/
export function storedFieldsToInspectorSettings(
stored: StoredInspectorFields,
stored: StoredInspectorFields & {
env?: Record<string, string>;
cwd?: string;
},
): InspectorServerSettings | undefined {
const hasAny =
stored.headers !== undefined ||
@ -122,7 +163,9 @@ export function storedFieldsToInspectorSettings(
stored.autoRefreshOnListChanged !== undefined ||
stored.maxFetchRequests !== undefined ||
stored.oauth !== undefined ||
stored.roots !== undefined;
stored.roots !== undefined ||
stored.env !== undefined ||
stored.cwd !== undefined;
if (!hasAny) return undefined;
const headersPairs: { key: string; value: string }[] = stored.headers
@ -131,6 +174,7 @@ export function storedFieldsToInspectorSettings(
const settings: InspectorServerSettings = {
headers: headersPairs,
env: envRecordToPairs(stored.env),
metadata: stored.metadata ?? [],
connectionTimeout: stored.connectionTimeout ?? 0,
requestTimeout: stored.requestTimeout ?? 0,
@ -155,6 +199,9 @@ export function storedFieldsToInspectorSettings(
if (stored.oauth?.clientSecret)
settings.oauthClientSecret = stored.oauth.clientSecret;
if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes;
// Mirror the stdio working directory for the form. Like the OAuth fields, an
// empty string coerces to absent so the form's "(inherit)" placeholder shows.
if (stored.cwd) settings.cwd = stored.cwd;
return settings;
}
@ -323,7 +370,20 @@ export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[] {
config: normalizedConfig,
connection: { status: "disconnected" },
};
const settings = storedFieldsToInspectorSettings(inspectorFields);
// Mirror the stdio `env` / `cwd` (SDK config fields) into the settings so
// the Server Settings modal can edit them. They stay on `config` for the
// transport. Gate on the stdio type rather than blindly casting — a non-
// stdio config carries neither field, matching the modal's stdio-only UI.
const isStdio =
normalizedConfig.type === "stdio" || normalizedConfig.type === undefined;
const stdioConfig = isStdio
? (normalizedConfig as StdioServerConfig)
: undefined;
const settings = storedFieldsToInspectorSettings({
...inspectorFields,
env: stdioConfig?.env,
cwd: stdioConfig?.cwd,
});
if (settings !== undefined) entry.settings = settings;
return entry;
});

View file

@ -422,6 +422,22 @@ export const DEFAULT_MAX_FETCH_REQUESTS = 1000;
export interface InspectorServerSettings {
headers: { key: string; value: string }[];
metadata: { key: string; value: string }[];
/**
* Environment variables for stdio servers, edited as controlled key/value
* rows (mirrors `headers`). Only meaningful for stdio transports; non-stdio
* servers keep this an empty list. These do NOT live on disk as a settings
* field — they round-trip through the SDK config's `env` (the standard
* mcp.json location). The settings layer mirrors them for the form, and the
* `/api/servers` PUT route writes an edited list back onto `config.env` when
* the caller patches settings only. Empty-key rows are dropped on persist.
*/
env: { key: string; value: string }[];
/**
* Working directory for stdio servers (`config.cwd`). Like `env`, this is a
* mirror of the SDK config field rather than a persisted settings field;
* empty/unset means "inherit". Only meaningful for stdio transports.
*/
cwd?: string;
connectionTimeout: number;
requestTimeout: number;
/** TTL (ms) for tasks created via "Run as task". Defaults to 60000. */