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

View file

@ -13,6 +13,7 @@ const initialSettings: InspectorServerSettings = {
{ key: "Authorization", value: "Bearer token-abc-123" },
{ key: "X-Request-Id", value: "req-456" },
],
env: [],
metadata: [{ key: "userId", value: "user-789" }],
connectionTimeout: 30000,
requestTimeout: 60000,
@ -52,6 +53,7 @@ const meta: Meta<typeof ServerSettingsModal> = {
render: InteractiveRender,
args: {
opened: true,
isStdio: false,
onClose: fn(),
onSettingsChange: fn(),
},
@ -70,6 +72,7 @@ export const EmptySettings: Story = {
args: {
settings: {
headers: [],
env: [],
metadata: [],
connectionTimeout: 30000,
requestTimeout: 60000,
@ -79,3 +82,25 @@ export const EmptySettings: Story = {
},
},
};
// A stdio server surfaces the Working Directory field (Options) and a dedicated
// Environment Variables section.
export const StdioServer: Story = {
args: {
isStdio: true,
settings: {
headers: [],
env: [
{ key: "API_KEY", value: "abc-123" },
{ key: "LOG_LEVEL", value: "debug" },
],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 1000,
cwd: "/srv/my-server",
roots: [],
},
},
};

View file

@ -6,6 +6,7 @@ import { ServerSettingsModal } from "./ServerSettingsModal";
const initialSettings: InspectorServerSettings = {
headers: [{ key: "Authorization", value: "Bearer abc" }],
env: [],
metadata: [{ key: "userId", value: "u-1" }],
connectionTimeout: 30000,
requestTimeout: 60000,
@ -19,6 +20,7 @@ const initialSettings: InspectorServerSettings = {
const emptySettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
connectionTimeout: 30000,
requestTimeout: 60000,
@ -33,6 +35,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened={false}
settings={emptySettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
@ -45,6 +48,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
@ -60,6 +64,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
isStdio={false}
onClose={onClose}
onSettingsChange={vi.fn()}
/>,
@ -80,6 +85,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
@ -99,6 +105,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={initialSettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
@ -119,6 +126,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={initialSettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
@ -140,6 +148,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
@ -159,6 +168,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={initialSettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
@ -181,6 +191,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={initialSettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
@ -201,6 +212,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
@ -220,6 +232,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
@ -239,6 +252,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
@ -258,6 +272,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={initialSettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
@ -279,6 +294,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={initialSettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
@ -299,6 +315,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
@ -333,4 +350,112 @@ describe("ServerSettingsModal", () => {
expect(optionsControl.getAttribute("aria-expanded")).toBe("false");
expect(headersControl.getAttribute("aria-expanded")).toBe("false");
});
describe("stdio env / cwd handlers", () => {
// The Options section is expanded on open, so the stdio fields are visible
// immediately when isStdio is true.
it("does not render the stdio fields when isStdio is false", () => {
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(
screen.queryByLabelText(/Working Directory/),
).not.toBeInTheDocument();
});
it("calls onSettingsChange when adding an environment variable", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// The Environment Variables section is collapsed on open — expand it.
await user.click(
screen.getByRole("button", { name: "Environment Variables" }),
);
await user.click(
screen.getByRole("button", { name: "+ Add Environment Variable" }),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
env: [{ key: "", value: "" }],
});
});
it("calls onSettingsChange when removing an environment variable", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, env: [{ key: "A", value: "1" }] }}
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("button", { name: "Environment Variables" }),
);
const removeButtons = screen.getAllByRole("button", { name: "X" });
await user.click(removeButtons[removeButtons.length - 1]);
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
env: [],
});
});
it("calls onSettingsChange with the updated env value when typing", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, env: [{ key: "A", value: "1" }] }}
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("button", { name: "Environment Variables" }),
);
await user.type(screen.getByDisplayValue("1"), "2");
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
expect(lastCall.env[0]).toEqual({ key: "A", value: "12" });
});
it("calls onSettingsChange when editing the working directory", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={{ ...emptySettings, cwd: "/srv" }}
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
// Controlled input: the value prop stays "/srv" (the parent vi.fn does not
// feed edits back), so a single keystroke appends to that base.
await user.type(screen.getByLabelText(/Working Directory/), "X");
expect(onSettingsChange).toHaveBeenLastCalledWith({
...emptySettings,
cwd: "/srvX",
});
});
});
});

View file

@ -10,18 +10,30 @@ import {
type ServerSettingsSection,
} from "../ServerSettingsForm/ServerSettingsForm";
const ALL_SECTIONS: ServerSettingsSection[] = [
"options",
"headers",
"metadata",
"timeouts",
"oauth",
"roots",
];
// The "environment" section only renders for stdio servers, so it joins the
// expand/collapse-all set only then — otherwise `allExpanded` could never be
// reached (the section isn't in the DOM to expand).
function allSectionsFor(isStdio: boolean): ServerSettingsSection[] {
return [
"options",
...(isStdio ? (["environment"] as const) : []),
"headers",
"metadata",
"timeouts",
"oauth",
"roots",
];
}
export interface ServerSettingsModalProps {
opened: boolean;
settings: InspectorServerSettings;
/**
* Whether the target server uses the stdio transport. Forwarded to the form
* to gate the stdio-only Working Directory field and Environment Variables
* section.
*/
isStdio: boolean;
onClose: () => void;
onSettingsChange: (settings: InspectorServerSettings) => void;
}
@ -29,6 +41,7 @@ export interface ServerSettingsModalProps {
export function ServerSettingsModal({
opened,
settings,
isStdio,
onClose,
onSettingsChange,
}: ServerSettingsModalProps) {
@ -41,10 +54,11 @@ export function ServerSettingsModal({
ServerSettingsSection[]
>(["options"]);
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
const allSections = allSectionsFor(isStdio);
const allExpanded = expandedSections.length === allSections.length;
function handleToggleAll() {
setExpandedSections(allExpanded ? [] : ALL_SECTIONS);
setExpandedSections(allExpanded ? [] : allSections);
}
function handleAddHeader() {
@ -68,6 +82,29 @@ export function ServerSettingsModal({
onSettingsChange({ ...settings, headers });
}
function handleAddEnv() {
onSettingsChange({
...settings,
env: [...settings.env, { key: "", value: "" }],
});
}
function handleRemoveEnv(index: number) {
onSettingsChange({
...settings,
env: settings.env.filter((_, i) => i !== index),
});
}
function handleEnvChange(index: number, key: string, value: string) {
const env = settings.env.map((e, i) => (i === index ? { key, value } : e));
onSettingsChange({ ...settings, env });
}
function handleCwdChange(value: string) {
onSettingsChange({ ...settings, cwd: value });
}
function handleAddMetadata() {
onSettingsChange({
...settings,
@ -158,11 +195,16 @@ export function ServerSettingsModal({
</Group>
<ServerSettingsForm
settings={settings}
isStdio={isStdio}
expandedSections={expandedSections}
onExpandedSectionsChange={setExpandedSections}
onAddHeader={handleAddHeader}
onRemoveHeader={handleRemoveHeader}
onHeaderChange={handleHeaderChange}
onAddEnv={handleAddEnv}
onRemoveEnv={handleRemoveEnv}
onEnvChange={handleEnvChange}
onCwdChange={handleCwdChange}
onAddMetadata={handleAddMetadata}
onRemoveMetadata={handleRemoveMetadata}
onMetadataChange={handleMetadataChange}