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

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;
});