fix(web): carry a landed stdio env/cwd onto the config it connects with

The Server Settings modal edits a stdio server's `env` and `cwd` as
settings, while the transport reads them off the *config* — so #2091's
tracker, which resolves settings through the last write known to have
reached disk, left those two behind: `server.config` still came straight
off a `servers` entry that stops advancing once a list read fails. A save
that landed during such a window kept spawning the child process with the
pre-save environment, while the modal (re-seeded from the tracker) showed
the new value. Credentials live in `env`, so the symptom is the inspected
server failing to authorize rather than a visibly lost edit.

Adds the settings→config mapping to `core/mcp/serverList.ts` — the one
place both sides of the wire can share — and routes the `/api/servers` PUT
write-through and the web client's connect path through it, so the value
persisted and the value spawned cannot be derived differently.

Closes #2096

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-25 00:36:09 -04:00
commit d9b5f4f8a7

View file

@ -197,6 +197,62 @@ export function envPairsToRecord(
return out;
}
/**
* The stdio `env` / `cwd` **config** fields implied by a settings mirror.
* `undefined` means "the field is not set" — i.e. remove it — which is how a
* user clears a value through the Server Settings modal (empty env list, blank
* cwd).
*
* `env` and `cwd` are the two fields the modal edits as *settings* while the
* transport reads them off *config* (see `storedFieldsToInspectorSettings`), so
* every consumer that has settings and needs a config has to perform this same
* mapping. It lives here, once, because the two consumers are on opposite sides
* of the wire — the `/api/servers` PUT write-through persists it, and the web
* client applies it when constructing an `InspectorClient` (#2096) — and a
* disagreement between them is invisible until a spawned child process gets an
* environment the file does not describe.
*/
export function stdioConfigFieldsFromSettings(
settings: InspectorServerSettings,
): { env: Record<string, string> | undefined; cwd: string | undefined } {
const env = envPairsToRecord(settings.env);
const cwd = settings.cwd?.trim();
return {
env: Object.keys(env).length > 0 ? env : undefined,
cwd: cwd ? cwd : undefined,
};
}
/**
* A copy of `config` with the stdio `env` / `cwd` taken from `settings`.
*
* Used where a caller holds a settings value it trusts more than the config it
* was read alongside — the web client's connect path, which resolves settings
* through the last-persisted-write tracker while `config` still comes off a
* `servers` entry that stops advancing once a list read fails (#2089). Without
* this the two disagree and the child process is spawned with the *pre-save*
* environment even though the save reached disk (#2096).
*
* Non-stdio configs are returned untouched — they carry neither field, matching
* the modal's stdio-only UI. Absent settings likewise: a server with no settings
* node has nothing to apply, and treating that as an empty mirror would clear a
* config `env` the file does hold.
*/
export function applyStdioSettingsToConfig(
config: MCPServerConfig,
settings: InspectorServerSettings | undefined,
): MCPServerConfig {
if (!settings) return config;
if (!(config.type === "stdio" || config.type === undefined)) return config;
const { env, cwd } = stdioConfigFieldsFromSettings(settings);
const next: StdioServerConfig = { ...(config as StdioServerConfig) };
if (env) next.env = env;
else delete next.env;
if (cwd) next.cwd = cwd;
else delete next.cwd;
return next;
}
/**
* Read a server's `metadata` off disk into the in-memory `RequestMetadata`
* object, accepting both the current object shape and the pre-#1910