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

@ -9,6 +9,7 @@ import {
const defaultSettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
connectionTimeout: 30000,
requestTimeout: 60000,
@ -85,6 +86,39 @@ function InteractiveRender(args: ServerSettingsFormProps) {
},
});
}}
onAddEnv={() => {
args.onAddEnv();
updateArgs({
settings: {
...args.settings,
env: [...args.settings.env, { key: "", value: "" }],
},
});
}}
onRemoveEnv={(index) => {
args.onRemoveEnv(index);
updateArgs({
settings: {
...args.settings,
env: args.settings.env.filter((_, i) => i !== index),
},
});
}}
onEnvChange={(index, key, value) => {
args.onEnvChange(index, key, value);
updateArgs({
settings: {
...args.settings,
env: args.settings.env.map((e, i) =>
i === index ? { key, value } : e,
),
},
});
}}
onCwdChange={(value) => {
args.onCwdChange(value);
updateArgs({ settings: { ...args.settings, cwd: value } });
}}
onTimeoutChange={(field, value) => {
args.onTimeoutChange(field, value);
updateArgs({
@ -123,11 +157,16 @@ const meta: Meta<typeof ServerSettingsForm> = {
component: ServerSettingsForm,
render: InteractiveRender,
args: {
isStdio: false,
expandedSections: ["options"],
onExpandedSectionsChange: fn(),
onAddHeader: fn(),
onRemoveHeader: fn(),
onHeaderChange: fn(),
onAddEnv: fn(),
onRemoveEnv: fn(),
onEnvChange: fn(),
onCwdChange: fn(),
onAddMetadata: fn(),
onRemoveMetadata: fn(),
onMetadataChange: fn(),
@ -178,6 +217,7 @@ export const AllConfigured: Story = {
{ key: "Authorization", value: "Bearer token-abc-123" },
{ key: "X-Request-Id", value: "req-456" },
],
env: [],
metadata: [
{ key: "userId", value: "user-789" },
{ key: "sessionId", value: "session-012" },
@ -196,3 +236,18 @@ export const AllConfigured: Story = {
},
},
};
export const StdioEnvironment: Story = {
args: {
isStdio: true,
expandedSections: ["options", "environment"],
settings: {
...defaultSettings,
cwd: "/srv/my-server",
env: [
{ key: "API_KEY", value: "abc-123" },
{ key: "LOG_LEVEL", value: "debug" },
],
},
},
};

View file

@ -18,6 +18,7 @@ function clearButtonFor(input: HTMLElement): HTMLElement {
const emptySettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
connectionTimeout: 30000,
requestTimeout: 60000,
@ -28,6 +29,7 @@ const emptySettings: InspectorServerSettings = {
const populatedSettings: InspectorServerSettings = {
headers: [{ key: "Authorization", value: "Bearer abc" }],
env: [],
metadata: [{ key: "userId", value: "u-1" }],
connectionTimeout: 30000,
requestTimeout: 60000,
@ -48,10 +50,16 @@ const allSections: ServerSettingsSection[] = [
];
const baseHandlers = {
// Default to non-stdio; the stdio-only env / cwd tests override this to true.
isStdio: false,
onExpandedSectionsChange: vi.fn(),
onAddHeader: vi.fn(),
onRemoveHeader: vi.fn(),
onHeaderChange: vi.fn(),
onAddEnv: vi.fn(),
onRemoveEnv: vi.fn(),
onEnvChange: vi.fn(),
onCwdChange: vi.fn(),
onAddMetadata: vi.fn(),
onRemoveMetadata: vi.fn(),
onMetadataChange: vi.fn(),
@ -345,6 +353,128 @@ describe("ServerSettingsForm", () => {
expect(onMaxFetchRequestsChange).toHaveBeenLastCalledWith(2500);
});
describe("stdio Working Directory (Options) / Environment Variables section", () => {
it("hides the Working Directory field and the Environment Variables section for non-stdio servers", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
isStdio={false}
settings={emptySettings}
expandedSections={["options", "environment"]}
/>,
);
expect(
screen.queryByLabelText(/Working Directory/),
).not.toBeInTheDocument();
// The whole Environment Variables accordion section is absent.
expect(
screen.queryByRole("button", { name: "Environment Variables" }),
).not.toBeInTheDocument();
expect(
screen.queryByText(/No environment variables configured/),
).not.toBeInTheDocument();
});
it("shows the Working Directory field in Options and the Environment Variables section for stdio servers", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
isStdio
settings={emptySettings}
expandedSections={["options", "environment"]}
/>,
);
expect(screen.getByLabelText(/Working Directory/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Environment Variables" }),
).toBeInTheDocument();
expect(
screen.getByText(/No environment variables configured/),
).toBeInTheDocument();
});
it("reflects the cwd value and invokes onCwdChange when typing", async () => {
const user = userEvent.setup();
const onCwdChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
isStdio
onCwdChange={onCwdChange}
settings={{ ...emptySettings, cwd: "/srv" }}
expandedSections={["options"]}
/>,
);
const input = screen.getByLabelText(/Working Directory/);
expect(input).toHaveValue("/srv");
await user.type(input, "X");
expect(onCwdChange).toHaveBeenLastCalledWith("/srvX");
});
it("clears the cwd via its Clear button", async () => {
const user = userEvent.setup();
const onCwdChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
isStdio
onCwdChange={onCwdChange}
settings={{ ...emptySettings, cwd: "/srv" }}
expandedSections={["options"]}
/>,
);
await user.click(
clearButtonFor(screen.getByLabelText(/Working Directory/)),
);
expect(onCwdChange).toHaveBeenCalledWith("");
});
it("invokes onAddEnv when the Add Environment Variable button is clicked", async () => {
const user = userEvent.setup();
const onAddEnv = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
isStdio
onAddEnv={onAddEnv}
settings={emptySettings}
expandedSections={["environment"]}
/>,
);
await user.click(
screen.getByRole("button", { name: /Add Environment Variable/ }),
);
expect(onAddEnv).toHaveBeenCalledTimes(1);
});
it("renders env rows and invokes onEnvChange / onRemoveEnv", async () => {
const user = userEvent.setup();
const onEnvChange = vi.fn();
const onRemoveEnv = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
isStdio
onEnvChange={onEnvChange}
onRemoveEnv={onRemoveEnv}
settings={{
...emptySettings,
env: [{ key: "API_KEY", value: "secret" }],
}}
expandedSections={["environment"]}
/>,
);
const keyInput = screen.getByDisplayValue("API_KEY");
await user.type(keyInput, "2");
expect(onEnvChange).toHaveBeenLastCalledWith(0, "API_KEY2", "secret");
// The remove ("X") button sits alongside the row's key/value inputs.
const removeButtons = screen.getAllByRole("button", { name: "X" });
await user.click(removeButtons[removeButtons.length - 1]!);
expect(onRemoveEnv).toHaveBeenCalledWith(0);
});
});
it("invokes onOAuthChange when typing in client id", async () => {
const user = userEvent.setup();
const onOAuthChange = vi.fn();

View file

@ -18,6 +18,7 @@ import type { Root } from "@modelcontextprotocol/sdk/types.js";
export type ServerSettingsSection =
| "options"
| "environment"
| "headers"
| "metadata"
| "timeouts"
@ -26,11 +27,21 @@ export type ServerSettingsSection =
export interface ServerSettingsFormProps {
settings: InspectorServerSettings;
/**
* Whether the target server uses the stdio transport. Gates the Working
* Directory field and Environment Variables section in the Options area —
* both are stdio-only config concepts and hidden for sse / streamable-http.
*/
isStdio: boolean;
expandedSections: ServerSettingsSection[];
onExpandedSectionsChange: (sections: ServerSettingsSection[]) => void;
onAddHeader: () => void;
onRemoveHeader: (index: number) => void;
onHeaderChange: (index: number, key: string, value: string) => void;
onAddEnv: () => void;
onRemoveEnv: (index: number) => void;
onEnvChange: (index: number, key: string, value: string) => void;
onCwdChange: (value: string) => void;
onAddMetadata: () => void;
onRemoveMetadata: (index: number) => void;
onMetadataChange: (index: number, key: string, value: string) => void;
@ -165,11 +176,16 @@ function RootRows({
export function ServerSettingsForm({
settings,
isStdio,
expandedSections,
onExpandedSectionsChange,
onAddHeader,
onRemoveHeader,
onHeaderChange,
onAddEnv,
onRemoveEnv,
onEnvChange,
onCwdChange,
onAddMetadata,
onRemoveMetadata,
onMetadataChange,
@ -239,10 +255,52 @@ export function ServerSettingsForm({
value={settings.maxFetchRequests}
onChange={handleMaxFetchRequestsChange}
/>
{isStdio ? (
<TextInput
label="Working Directory"
description="Directory the stdio server process is launched in. Leave empty to inherit the Inspector's working directory."
placeholder="(inherit)"
value={settings.cwd ?? ""}
onChange={(e) => onCwdChange(e.currentTarget.value)}
rightSectionPointerEvents="auto"
rightSection={
settings.cwd ? (
<ClearButton onClick={() => onCwdChange("")} />
) : null
}
/>
) : null}
</Stack>
</Accordion.Panel>
</Accordion.Item>
{isStdio ? (
<Accordion.Item value="environment">
<Accordion.Control>Environment Variables</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<Group justify="space-between">
<HintText>
Environment variables passed to the stdio server process.
</HintText>
<AddButton onClick={onAddEnv}>
+ Add Environment Variable
</AddButton>
</Group>
{settings.env.length === 0 ? (
<EmptyHint>No environment variables configured</EmptyHint>
) : (
<KeyValueRows
items={settings.env}
onChange={onEnvChange}
onRemove={onRemoveEnv}
/>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
) : null}
<Accordion.Item value="headers">
<Accordion.Control>Custom Headers</Accordion.Control>
<Accordion.Panel>