* 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:
parent
c60a128df3
commit
5ee347571f
1 changed files with 1116 additions and 18 deletions
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue