* 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
3 changed files with 1116 additions and 18 deletions
|
|
@ -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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue