* 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
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue