* 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
26 changed files with 1116 additions and 18 deletions
|
|
@ -1261,6 +1261,7 @@ const settingsWithRoots = (
|
||||||
roots: InspectorServerSettings["roots"],
|
roots: InspectorServerSettings["roots"],
|
||||||
): InspectorServerSettings => ({
|
): InspectorServerSettings => ({
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import type {
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
import { getServerType } from "@inspector/core/mcp/config.js";
|
||||||
import type {
|
import type {
|
||||||
InspectorClientEventMap,
|
InspectorClientEventMap,
|
||||||
JsonValue,
|
JsonValue,
|
||||||
|
|
@ -282,6 +283,7 @@ async function replayHistoryRequest(
|
||||||
// React doesn't re-allocate on every render.
|
// React doesn't re-allocate on every render.
|
||||||
const EMPTY_SETTINGS: InspectorServerSettings = {
|
const EMPTY_SETTINGS: InspectorServerSettings = {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
@ -2257,6 +2259,16 @@ function App() {
|
||||||
const settingsModalValue: InspectorServerSettings =
|
const settingsModalValue: InspectorServerSettings =
|
||||||
settingsDraft ?? EMPTY_SETTINGS;
|
settingsDraft ?? EMPTY_SETTINGS;
|
||||||
|
|
||||||
|
// Gate the stdio-only Working Directory / Environment Variables controls in
|
||||||
|
// the settings modal. Resolve the target server's transport from its config;
|
||||||
|
// default to non-stdio when the target isn't resolvable (modal closed).
|
||||||
|
const settingsModalTarget = servers.find(
|
||||||
|
(s) => s.id === settingsModalTargetId,
|
||||||
|
);
|
||||||
|
const settingsModalIsStdio = settingsModalTarget
|
||||||
|
? getServerType(settingsModalTarget.config) === "stdio"
|
||||||
|
: false;
|
||||||
|
|
||||||
const onSettingsModalClose = useCallback(() => {
|
const onSettingsModalClose = useCallback(() => {
|
||||||
flushSettingsDraft();
|
flushSettingsDraft();
|
||||||
// Apply root edits to the live client once, on close — not on every
|
// Apply root edits to the live client once, on close — not on every
|
||||||
|
|
@ -2551,6 +2563,7 @@ function App() {
|
||||||
key={settingsModalTargetId ?? "closed"}
|
key={settingsModalTargetId ?? "closed"}
|
||||||
opened={settingsModalTargetId !== undefined}
|
opened={settingsModalTargetId !== undefined}
|
||||||
settings={settingsModalValue}
|
settings={settingsModalValue}
|
||||||
|
isStdio={settingsModalIsStdio}
|
||||||
onClose={onSettingsModalClose}
|
onClose={onSettingsModalClose}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
|
|
||||||
const defaultSettings: InspectorServerSettings = {
|
const defaultSettings: InspectorServerSettings = {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
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) => {
|
onTimeoutChange={(field, value) => {
|
||||||
args.onTimeoutChange(field, value);
|
args.onTimeoutChange(field, value);
|
||||||
updateArgs({
|
updateArgs({
|
||||||
|
|
@ -123,11 +157,16 @@ const meta: Meta<typeof ServerSettingsForm> = {
|
||||||
component: ServerSettingsForm,
|
component: ServerSettingsForm,
|
||||||
render: InteractiveRender,
|
render: InteractiveRender,
|
||||||
args: {
|
args: {
|
||||||
|
isStdio: false,
|
||||||
expandedSections: ["options"],
|
expandedSections: ["options"],
|
||||||
onExpandedSectionsChange: fn(),
|
onExpandedSectionsChange: fn(),
|
||||||
onAddHeader: fn(),
|
onAddHeader: fn(),
|
||||||
onRemoveHeader: fn(),
|
onRemoveHeader: fn(),
|
||||||
onHeaderChange: fn(),
|
onHeaderChange: fn(),
|
||||||
|
onAddEnv: fn(),
|
||||||
|
onRemoveEnv: fn(),
|
||||||
|
onEnvChange: fn(),
|
||||||
|
onCwdChange: fn(),
|
||||||
onAddMetadata: fn(),
|
onAddMetadata: fn(),
|
||||||
onRemoveMetadata: fn(),
|
onRemoveMetadata: fn(),
|
||||||
onMetadataChange: fn(),
|
onMetadataChange: fn(),
|
||||||
|
|
@ -178,6 +217,7 @@ export const AllConfigured: Story = {
|
||||||
{ key: "Authorization", value: "Bearer token-abc-123" },
|
{ key: "Authorization", value: "Bearer token-abc-123" },
|
||||||
{ key: "X-Request-Id", value: "req-456" },
|
{ key: "X-Request-Id", value: "req-456" },
|
||||||
],
|
],
|
||||||
|
env: [],
|
||||||
metadata: [
|
metadata: [
|
||||||
{ key: "userId", value: "user-789" },
|
{ key: "userId", value: "user-789" },
|
||||||
{ key: "sessionId", value: "session-012" },
|
{ 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 = {
|
const emptySettings: InspectorServerSettings = {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
requestTimeout: 60000,
|
||||||
|
|
@ -28,6 +29,7 @@ const emptySettings: InspectorServerSettings = {
|
||||||
|
|
||||||
const populatedSettings: InspectorServerSettings = {
|
const populatedSettings: InspectorServerSettings = {
|
||||||
headers: [{ key: "Authorization", value: "Bearer abc" }],
|
headers: [{ key: "Authorization", value: "Bearer abc" }],
|
||||||
|
env: [],
|
||||||
metadata: [{ key: "userId", value: "u-1" }],
|
metadata: [{ key: "userId", value: "u-1" }],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
requestTimeout: 60000,
|
||||||
|
|
@ -48,10 +50,16 @@ const allSections: ServerSettingsSection[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const baseHandlers = {
|
const baseHandlers = {
|
||||||
|
// Default to non-stdio; the stdio-only env / cwd tests override this to true.
|
||||||
|
isStdio: false,
|
||||||
onExpandedSectionsChange: vi.fn(),
|
onExpandedSectionsChange: vi.fn(),
|
||||||
onAddHeader: vi.fn(),
|
onAddHeader: vi.fn(),
|
||||||
onRemoveHeader: vi.fn(),
|
onRemoveHeader: vi.fn(),
|
||||||
onHeaderChange: vi.fn(),
|
onHeaderChange: vi.fn(),
|
||||||
|
onAddEnv: vi.fn(),
|
||||||
|
onRemoveEnv: vi.fn(),
|
||||||
|
onEnvChange: vi.fn(),
|
||||||
|
onCwdChange: vi.fn(),
|
||||||
onAddMetadata: vi.fn(),
|
onAddMetadata: vi.fn(),
|
||||||
onRemoveMetadata: vi.fn(),
|
onRemoveMetadata: vi.fn(),
|
||||||
onMetadataChange: vi.fn(),
|
onMetadataChange: vi.fn(),
|
||||||
|
|
@ -345,6 +353,128 @@ describe("ServerSettingsForm", () => {
|
||||||
expect(onMaxFetchRequestsChange).toHaveBeenLastCalledWith(2500);
|
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 () => {
|
it("invokes onOAuthChange when typing in client id", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onOAuthChange = vi.fn();
|
const onOAuthChange = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import type { Root } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
export type ServerSettingsSection =
|
export type ServerSettingsSection =
|
||||||
| "options"
|
| "options"
|
||||||
|
| "environment"
|
||||||
| "headers"
|
| "headers"
|
||||||
| "metadata"
|
| "metadata"
|
||||||
| "timeouts"
|
| "timeouts"
|
||||||
|
|
@ -26,11 +27,21 @@ export type ServerSettingsSection =
|
||||||
|
|
||||||
export interface ServerSettingsFormProps {
|
export interface ServerSettingsFormProps {
|
||||||
settings: InspectorServerSettings;
|
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[];
|
expandedSections: ServerSettingsSection[];
|
||||||
onExpandedSectionsChange: (sections: ServerSettingsSection[]) => void;
|
onExpandedSectionsChange: (sections: ServerSettingsSection[]) => void;
|
||||||
onAddHeader: () => void;
|
onAddHeader: () => void;
|
||||||
onRemoveHeader: (index: number) => void;
|
onRemoveHeader: (index: number) => void;
|
||||||
onHeaderChange: (index: number, key: string, value: string) => 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;
|
onAddMetadata: () => void;
|
||||||
onRemoveMetadata: (index: number) => void;
|
onRemoveMetadata: (index: number) => void;
|
||||||
onMetadataChange: (index: number, key: string, value: string) => void;
|
onMetadataChange: (index: number, key: string, value: string) => void;
|
||||||
|
|
@ -165,11 +176,16 @@ function RootRows({
|
||||||
|
|
||||||
export function ServerSettingsForm({
|
export function ServerSettingsForm({
|
||||||
settings,
|
settings,
|
||||||
|
isStdio,
|
||||||
expandedSections,
|
expandedSections,
|
||||||
onExpandedSectionsChange,
|
onExpandedSectionsChange,
|
||||||
onAddHeader,
|
onAddHeader,
|
||||||
onRemoveHeader,
|
onRemoveHeader,
|
||||||
onHeaderChange,
|
onHeaderChange,
|
||||||
|
onAddEnv,
|
||||||
|
onRemoveEnv,
|
||||||
|
onEnvChange,
|
||||||
|
onCwdChange,
|
||||||
onAddMetadata,
|
onAddMetadata,
|
||||||
onRemoveMetadata,
|
onRemoveMetadata,
|
||||||
onMetadataChange,
|
onMetadataChange,
|
||||||
|
|
@ -239,10 +255,52 @@ export function ServerSettingsForm({
|
||||||
value={settings.maxFetchRequests}
|
value={settings.maxFetchRequests}
|
||||||
onChange={handleMaxFetchRequestsChange}
|
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>
|
</Stack>
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</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.Item value="headers">
|
||||||
<Accordion.Control>Custom Headers</Accordion.Control>
|
<Accordion.Control>Custom Headers</Accordion.Control>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const initialSettings: InspectorServerSettings = {
|
||||||
{ key: "Authorization", value: "Bearer token-abc-123" },
|
{ key: "Authorization", value: "Bearer token-abc-123" },
|
||||||
{ key: "X-Request-Id", value: "req-456" },
|
{ key: "X-Request-Id", value: "req-456" },
|
||||||
],
|
],
|
||||||
|
env: [],
|
||||||
metadata: [{ key: "userId", value: "user-789" }],
|
metadata: [{ key: "userId", value: "user-789" }],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
requestTimeout: 60000,
|
||||||
|
|
@ -52,6 +53,7 @@ const meta: Meta<typeof ServerSettingsModal> = {
|
||||||
render: InteractiveRender,
|
render: InteractiveRender,
|
||||||
args: {
|
args: {
|
||||||
opened: true,
|
opened: true,
|
||||||
|
isStdio: false,
|
||||||
onClose: fn(),
|
onClose: fn(),
|
||||||
onSettingsChange: fn(),
|
onSettingsChange: fn(),
|
||||||
},
|
},
|
||||||
|
|
@ -70,6 +72,7 @@ export const EmptySettings: Story = {
|
||||||
args: {
|
args: {
|
||||||
settings: {
|
settings: {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
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 = {
|
const initialSettings: InspectorServerSettings = {
|
||||||
headers: [{ key: "Authorization", value: "Bearer abc" }],
|
headers: [{ key: "Authorization", value: "Bearer abc" }],
|
||||||
|
env: [],
|
||||||
metadata: [{ key: "userId", value: "u-1" }],
|
metadata: [{ key: "userId", value: "u-1" }],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
requestTimeout: 60000,
|
||||||
|
|
@ -19,6 +20,7 @@ const initialSettings: InspectorServerSettings = {
|
||||||
|
|
||||||
const emptySettings: InspectorServerSettings = {
|
const emptySettings: InspectorServerSettings = {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
requestTimeout: 60000,
|
requestTimeout: 60000,
|
||||||
|
|
@ -33,6 +35,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened={false}
|
opened={false}
|
||||||
settings={emptySettings}
|
settings={emptySettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={vi.fn()}
|
onSettingsChange={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -45,6 +48,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={emptySettings}
|
settings={emptySettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={vi.fn()}
|
onSettingsChange={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -60,6 +64,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={emptySettings}
|
settings={emptySettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSettingsChange={vi.fn()}
|
onSettingsChange={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -80,6 +85,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={emptySettings}
|
settings={emptySettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -99,6 +105,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={initialSettings}
|
settings={initialSettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -119,6 +126,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={initialSettings}
|
settings={initialSettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -140,6 +148,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={emptySettings}
|
settings={emptySettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -159,6 +168,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={initialSettings}
|
settings={initialSettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -181,6 +191,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={initialSettings}
|
settings={initialSettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -201,6 +212,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={emptySettings}
|
settings={emptySettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -220,6 +232,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={emptySettings}
|
settings={emptySettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -239,6 +252,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={emptySettings}
|
settings={emptySettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -258,6 +272,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={initialSettings}
|
settings={initialSettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -279,6 +294,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={initialSettings}
|
settings={initialSettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={onSettingsChange}
|
onSettingsChange={onSettingsChange}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -299,6 +315,7 @@ describe("ServerSettingsModal", () => {
|
||||||
<ServerSettingsModal
|
<ServerSettingsModal
|
||||||
opened
|
opened
|
||||||
settings={emptySettings}
|
settings={emptySettings}
|
||||||
|
isStdio={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onSettingsChange={vi.fn()}
|
onSettingsChange={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -333,4 +350,112 @@ describe("ServerSettingsModal", () => {
|
||||||
expect(optionsControl.getAttribute("aria-expanded")).toBe("false");
|
expect(optionsControl.getAttribute("aria-expanded")).toBe("false");
|
||||||
expect(headersControl.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,
|
type ServerSettingsSection,
|
||||||
} from "../ServerSettingsForm/ServerSettingsForm";
|
} from "../ServerSettingsForm/ServerSettingsForm";
|
||||||
|
|
||||||
const ALL_SECTIONS: ServerSettingsSection[] = [
|
// The "environment" section only renders for stdio servers, so it joins the
|
||||||
"options",
|
// expand/collapse-all set only then — otherwise `allExpanded` could never be
|
||||||
"headers",
|
// reached (the section isn't in the DOM to expand).
|
||||||
"metadata",
|
function allSectionsFor(isStdio: boolean): ServerSettingsSection[] {
|
||||||
"timeouts",
|
return [
|
||||||
"oauth",
|
"options",
|
||||||
"roots",
|
...(isStdio ? (["environment"] as const) : []),
|
||||||
];
|
"headers",
|
||||||
|
"metadata",
|
||||||
|
"timeouts",
|
||||||
|
"oauth",
|
||||||
|
"roots",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerSettingsModalProps {
|
export interface ServerSettingsModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
settings: InspectorServerSettings;
|
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;
|
onClose: () => void;
|
||||||
onSettingsChange: (settings: InspectorServerSettings) => void;
|
onSettingsChange: (settings: InspectorServerSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -29,6 +41,7 @@ export interface ServerSettingsModalProps {
|
||||||
export function ServerSettingsModal({
|
export function ServerSettingsModal({
|
||||||
opened,
|
opened,
|
||||||
settings,
|
settings,
|
||||||
|
isStdio,
|
||||||
onClose,
|
onClose,
|
||||||
onSettingsChange,
|
onSettingsChange,
|
||||||
}: ServerSettingsModalProps) {
|
}: ServerSettingsModalProps) {
|
||||||
|
|
@ -41,10 +54,11 @@ export function ServerSettingsModal({
|
||||||
ServerSettingsSection[]
|
ServerSettingsSection[]
|
||||||
>(["options"]);
|
>(["options"]);
|
||||||
|
|
||||||
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
|
const allSections = allSectionsFor(isStdio);
|
||||||
|
const allExpanded = expandedSections.length === allSections.length;
|
||||||
|
|
||||||
function handleToggleAll() {
|
function handleToggleAll() {
|
||||||
setExpandedSections(allExpanded ? [] : ALL_SECTIONS);
|
setExpandedSections(allExpanded ? [] : allSections);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddHeader() {
|
function handleAddHeader() {
|
||||||
|
|
@ -68,6 +82,29 @@ export function ServerSettingsModal({
|
||||||
onSettingsChange({ ...settings, headers });
|
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() {
|
function handleAddMetadata() {
|
||||||
onSettingsChange({
|
onSettingsChange({
|
||||||
...settings,
|
...settings,
|
||||||
|
|
@ -158,11 +195,16 @@ export function ServerSettingsModal({
|
||||||
</Group>
|
</Group>
|
||||||
<ServerSettingsForm
|
<ServerSettingsForm
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
isStdio={isStdio}
|
||||||
expandedSections={expandedSections}
|
expandedSections={expandedSections}
|
||||||
onExpandedSectionsChange={setExpandedSections}
|
onExpandedSectionsChange={setExpandedSections}
|
||||||
onAddHeader={handleAddHeader}
|
onAddHeader={handleAddHeader}
|
||||||
onRemoveHeader={handleRemoveHeader}
|
onRemoveHeader={handleRemoveHeader}
|
||||||
onHeaderChange={handleHeaderChange}
|
onHeaderChange={handleHeaderChange}
|
||||||
|
onAddEnv={handleAddEnv}
|
||||||
|
onRemoveEnv={handleRemoveEnv}
|
||||||
|
onEnvChange={handleEnvChange}
|
||||||
|
onCwdChange={handleCwdChange}
|
||||||
onAddMetadata={handleAddMetadata}
|
onAddMetadata={handleAddMetadata}
|
||||||
onRemoveMetadata={handleRemoveMetadata}
|
onRemoveMetadata={handleRemoveMetadata}
|
||||||
onMetadataChange={handleMetadataChange}
|
onMetadataChange={handleMetadataChange}
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,7 @@ describe("RemoteClientTransport", () => {
|
||||||
});
|
});
|
||||||
const settings = {
|
const settings = {
|
||||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,17 @@ import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
cleanRoots,
|
cleanRoots,
|
||||||
DEFAULT_SEED_CONFIG,
|
DEFAULT_SEED_CONFIG,
|
||||||
|
envPairsToRecord,
|
||||||
|
envRecordToPairs,
|
||||||
expectedSecretFields,
|
expectedSecretFields,
|
||||||
extractSecretsFromStored,
|
extractSecretsFromStored,
|
||||||
|
inspectorSettingsToStoredFields,
|
||||||
mcpConfigToServerEntries,
|
mcpConfigToServerEntries,
|
||||||
mergeSecretsIntoStored,
|
mergeSecretsIntoStored,
|
||||||
normalizeServerType,
|
normalizeServerType,
|
||||||
serverEntriesToMcpConfig,
|
serverEntriesToMcpConfig,
|
||||||
serializeMcpConfig,
|
serializeMcpConfig,
|
||||||
|
storedFieldsToInspectorSettings,
|
||||||
} from "@inspector/core/mcp/serverList.js";
|
} from "@inspector/core/mcp/serverList.js";
|
||||||
import {
|
import {
|
||||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||||
|
|
@ -314,6 +318,8 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
expect(entry?.settings).toEqual({
|
expect(entry?.settings).toEqual({
|
||||||
// Object headers on disk → pair-array headers in memory
|
// Object headers on disk → pair-array headers in memory
|
||||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||||
|
// Non-stdio server → empty env mirror in memory (for the form)
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
@ -393,6 +399,7 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
{ key: "", value: "stub" },
|
{ key: "", value: "stub" },
|
||||||
{ key: " ", value: "whitespace" },
|
{ key: " ", value: "whitespace" },
|
||||||
],
|
],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
@ -419,6 +426,7 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
config: { type: "streamable-http", url: "https://x.test" },
|
config: { type: "streamable-http", url: "https://x.test" },
|
||||||
settings: {
|
settings: {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
@ -447,6 +455,7 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
config: { type: "stdio", command: "node" },
|
config: { type: "stdio", command: "node" },
|
||||||
settings: {
|
settings: {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
@ -485,6 +494,7 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
config: { type: "stdio", command: "node" },
|
config: { type: "stdio", command: "node" },
|
||||||
settings: {
|
settings: {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
@ -511,6 +521,7 @@ describe("serverEntriesToMcpConfig", () => {
|
||||||
config: { type: "stdio", command: "node" },
|
config: { type: "stdio", command: "node" },
|
||||||
settings: {
|
settings: {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
@ -777,3 +788,130 @@ describe("expectedSecretFields", () => {
|
||||||
).toEqual([SECRET_FIELD_OAUTH_CLIENT_SECRET]);
|
).toEqual([SECRET_FIELD_OAUTH_CLIENT_SECRET]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("envRecordToPairs / envPairsToRecord", () => {
|
||||||
|
it("envRecordToPairs preserves key insertion order", () => {
|
||||||
|
expect(envRecordToPairs({ B: "2", A: "1" })).toEqual([
|
||||||
|
{ key: "B", value: "2" },
|
||||||
|
{ key: "A", value: "1" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("envRecordToPairs returns an empty array for undefined", () => {
|
||||||
|
expect(envRecordToPairs(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("envPairsToRecord drops empty / whitespace-only keys", () => {
|
||||||
|
expect(
|
||||||
|
envPairsToRecord([
|
||||||
|
{ key: "API_KEY", value: "secret" },
|
||||||
|
{ key: "", value: "ignored" },
|
||||||
|
{ key: " ", value: "ignored" },
|
||||||
|
{ key: "DEBUG", value: "1" },
|
||||||
|
]),
|
||||||
|
).toEqual({ API_KEY: "secret", DEBUG: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a populated env through both converters", () => {
|
||||||
|
const record = { API_KEY: "secret", DEBUG: "1" };
|
||||||
|
expect(envPairsToRecord(envRecordToPairs(record))).toEqual(record);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stdio env / cwd mirroring", () => {
|
||||||
|
it("lifts stdio env (object → pair-array) and cwd onto settings while keeping them on config", () => {
|
||||||
|
const cfg: MCPConfig = {
|
||||||
|
mcpServers: {
|
||||||
|
alpha: {
|
||||||
|
type: "stdio",
|
||||||
|
command: "node",
|
||||||
|
args: ["server.js"],
|
||||||
|
env: { API_KEY: "secret", DEBUG: "1" },
|
||||||
|
cwd: "/srv/app",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const [entry] = mcpConfigToServerEntries(cfg);
|
||||||
|
expect(entry?.settings?.env).toEqual([
|
||||||
|
{ key: "API_KEY", value: "secret" },
|
||||||
|
{ key: "DEBUG", value: "1" },
|
||||||
|
]);
|
||||||
|
expect(entry?.settings?.cwd).toBe("/srv/app");
|
||||||
|
// env / cwd remain on the SDK config so the transport still sees them.
|
||||||
|
expect(entry?.config).toMatchObject({
|
||||||
|
env: { API_KEY: "secret", DEBUG: "1" },
|
||||||
|
cwd: "/srv/app",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("materializes a settings node for a bare stdio entry carrying only env", () => {
|
||||||
|
const cfg: MCPConfig = {
|
||||||
|
mcpServers: {
|
||||||
|
alpha: { type: "stdio", command: "node", env: { A: "1" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const [entry] = mcpConfigToServerEntries(cfg);
|
||||||
|
expect(entry?.settings?.env).toEqual([{ key: "A", value: "1" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves env an empty list and cwd unset for a stdio entry without them", () => {
|
||||||
|
const settings = storedFieldsToInspectorSettings({
|
||||||
|
headers: { "X-A": "b" },
|
||||||
|
});
|
||||||
|
expect(settings?.env).toEqual([]);
|
||||||
|
expect(settings?.cwd).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mirrors empty env for non-stdio servers", () => {
|
||||||
|
const cfg: MCPConfig = {
|
||||||
|
mcpServers: {
|
||||||
|
alpha: {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: "https://x.test/mcp",
|
||||||
|
headers: { "X-Tenant": "acme" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const [entry] = mcpConfigToServerEntries(cfg);
|
||||||
|
expect(entry?.settings?.env).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT re-emit env / cwd from inspectorSettingsToStoredFields (config owns them on disk)", () => {
|
||||||
|
// env / cwd are config fields; the write side is the PUT route's
|
||||||
|
// write-through, not this converter. So even a populated settings.env must
|
||||||
|
// not leak back as a settings delta that would clobber config on merge.
|
||||||
|
const out = inspectorSettingsToStoredFields({
|
||||||
|
headers: [],
|
||||||
|
env: [{ key: "API_KEY", value: "secret" }],
|
||||||
|
cwd: "/srv/app",
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
taskTtl: 60000,
|
||||||
|
maxFetchRequests: 1000,
|
||||||
|
roots: [],
|
||||||
|
});
|
||||||
|
expect(out).not.toHaveProperty("env");
|
||||||
|
expect(out).not.toHaveProperty("cwd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves stdio env / cwd through a config serialize round-trip", () => {
|
||||||
|
const cfg: MCPConfig = {
|
||||||
|
mcpServers: {
|
||||||
|
alpha: {
|
||||||
|
type: "stdio",
|
||||||
|
command: "node",
|
||||||
|
env: { API_KEY: "secret" },
|
||||||
|
cwd: "/srv/app",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const entries = mcpConfigToServerEntries(cfg);
|
||||||
|
const back = serverEntriesToMcpConfig(entries);
|
||||||
|
expect(back.mcpServers.alpha).toMatchObject({
|
||||||
|
command: "node",
|
||||||
|
env: { API_KEY: "secret" },
|
||||||
|
cwd: "/srv/app",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ function prompt(name: string): Prompt {
|
||||||
|
|
||||||
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ function template(name: string): ResourceTemplate {
|
||||||
|
|
||||||
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ function resource(uri: string): Resource {
|
||||||
|
|
||||||
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ function tool(name: string): Tool {
|
||||||
|
|
||||||
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
|
||||||
|
|
@ -411,6 +411,7 @@ describe("useServers", () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.updateServerSettings("alpha", {
|
await result.current.updateServerSettings("alpha", {
|
||||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||||
|
env: [],
|
||||||
metadata: [{ key: "trace", value: "abc" }],
|
metadata: [{ key: "trace", value: "abc" }],
|
||||||
connectionTimeout: 5000,
|
connectionTimeout: 5000,
|
||||||
requestTimeout: 30000,
|
requestTimeout: 30000,
|
||||||
|
|
@ -423,6 +424,7 @@ describe("useServers", () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.servers[0]?.settings).toEqual({
|
expect(result.current.servers[0]?.settings).toEqual({
|
||||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||||
|
env: [],
|
||||||
metadata: [{ key: "trace", value: "abc" }],
|
metadata: [{ key: "trace", value: "abc" }],
|
||||||
connectionTimeout: 5000,
|
connectionTimeout: 5000,
|
||||||
requestTimeout: 30000,
|
requestTimeout: 30000,
|
||||||
|
|
@ -457,6 +459,7 @@ describe("useServers", () => {
|
||||||
act(async () => {
|
act(async () => {
|
||||||
await result.current.updateServerSettings("nonexistent", {
|
await result.current.updateServerSettings("nonexistent", {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
@ -506,6 +509,7 @@ describe("useServers", () => {
|
||||||
});
|
});
|
||||||
expect(result.current.servers[0]?.settings).toEqual({
|
expect(result.current.servers[0]?.settings).toEqual({
|
||||||
headers: [{ key: "X-Keep", value: "yes" }],
|
headers: [{ key: "X-Keep", value: "yes" }],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,14 @@ describe("parseMcpServersConfig", () => {
|
||||||
expect(config.mcpServers.sse.type).toBe("sse");
|
expect(config.mcpServers.sse.type).toBe("sse");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves extension fields (env, headers) on entries", () => {
|
it("preserves extension fields (env, cwd, headers) on entries", () => {
|
||||||
const raw = JSON.stringify({
|
const raw = JSON.stringify({
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
s: {
|
s: {
|
||||||
command: "node",
|
command: "node",
|
||||||
args: ["server.js"],
|
args: ["server.js"],
|
||||||
env: { API_KEY: "abc" },
|
env: { API_KEY: "abc" },
|
||||||
|
cwd: "/srv/app",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -37,6 +38,7 @@ describe("parseMcpServersConfig", () => {
|
||||||
expect(entry.type).toBe("stdio");
|
expect(entry.type).toBe("stdio");
|
||||||
if (entry.type === "stdio") {
|
if (entry.type === "stdio") {
|
||||||
expect(entry.env).toEqual({ API_KEY: "abc" });
|
expect(entry.env).toEqual({ API_KEY: "abc" });
|
||||||
|
expect(entry.cwd).toBe("/srv/app");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,9 @@ describe("parseServerJson — packages", () => {
|
||||||
// surfaced for the user to fill.
|
// surfaced for the user to fill.
|
||||||
if (opt.baseConfig.type === "stdio") {
|
if (opt.baseConfig.type === "stdio") {
|
||||||
expect(opt.baseConfig.env).toEqual({ LOG_LEVEL: "info" });
|
expect(opt.baseConfig.env).toEqual({ LOG_LEVEL: "info" });
|
||||||
|
// The registry server.json schema carries no working-directory concept,
|
||||||
|
// so a parsed package never sets cwd (documented in serverJson.ts).
|
||||||
|
expect(opt.baseConfig.cwd).toBeUndefined();
|
||||||
}
|
}
|
||||||
expect(opt.envVars).toEqual([
|
expect(opt.envVars).toEqual([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,7 @@ describe("InspectorClient", () => {
|
||||||
environment: { transport: fakeFactory },
|
environment: { transport: fakeFactory },
|
||||||
serverSettings: {
|
serverSettings: {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 50,
|
connectionTimeout: 50,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
@ -660,6 +661,7 @@ describe("InspectorClient", () => {
|
||||||
it("getServerSettings returns the constructor settings; setServerSettings replaces them live (#1444)", () => {
|
it("getServerSettings returns the constructor settings; setServerSettings replaces them live (#1444)", () => {
|
||||||
const initial = {
|
const initial = {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,7 @@ describe("Transport", () => {
|
||||||
{ key: "X-Trace", value: "abc123" },
|
{ key: "X-Trace", value: "abc123" },
|
||||||
{ key: "", value: "ignored-empty-key" },
|
{ key: "", value: "ignored-empty-key" },
|
||||||
],
|
],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
@ -267,6 +268,7 @@ describe("Transport", () => {
|
||||||
const config: MCPServerConfig = { type: "sse", url: server.url };
|
const config: MCPServerConfig = { type: "sse", url: server.url };
|
||||||
const settings: InspectorServerSettings = {
|
const settings: InspectorServerSettings = {
|
||||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
@ -311,6 +313,7 @@ describe("Transport", () => {
|
||||||
};
|
};
|
||||||
const settings: InspectorServerSettings = {
|
const settings: InspectorServerSettings = {
|
||||||
headers: [],
|
headers: [],
|
||||||
|
env: [],
|
||||||
metadata: [],
|
metadata: [],
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
requestTimeout: 0,
|
requestTimeout: 0,
|
||||||
|
|
|
||||||
|
|
@ -1233,6 +1233,282 @@ describe("/api/servers routes", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("stdio env / cwd write-through (settings modal)", () => {
|
||||||
|
it("writes settings.env / settings.cwd onto a stdio config on a settings-only PUT", async () => {
|
||||||
|
writeFileSync(
|
||||||
|
h.configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: { srv: { type: "stdio", command: "node" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: {
|
||||||
|
headers: [],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
env: [{ key: "API_KEY", value: "abc-123" }],
|
||||||
|
cwd: "/srv/app",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const stored = readConfig(h.configPath).mcpServers.srv as {
|
||||||
|
env?: Record<string, string>;
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
// env key persisted on the config (value stripped to keychain), cwd set.
|
||||||
|
expect(stored.env).toEqual({ API_KEY: "" });
|
||||||
|
expect(stored.cwd).toBe("/srv/app");
|
||||||
|
expect(await h.secretStore.get("srv", envSecretField("API_KEY"))).toBe(
|
||||||
|
"abc-123",
|
||||||
|
);
|
||||||
|
// env / cwd are NOT persisted as a settings wrapper — they live on config.
|
||||||
|
expect(stored).not.toHaveProperty("settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves config.env / cwd and the keychain when a settings-only PUT omits them", async () => {
|
||||||
|
// An env-unaware caller patching just a timeout must not wipe the stored
|
||||||
|
// env/cwd. Absent env/cwd on the wire => preserve, distinct from explicit
|
||||||
|
// empty => clear.
|
||||||
|
writeFileSync(
|
||||||
|
h.configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
srv: {
|
||||||
|
type: "stdio",
|
||||||
|
command: "node",
|
||||||
|
env: { API_KEY: "" },
|
||||||
|
cwd: "/srv/app",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await h.secretStore.set("srv", envSecretField("API_KEY"), "abc-123");
|
||||||
|
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
// env / cwd intentionally omitted; only a timeout is patched.
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: {
|
||||||
|
headers: [],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 5000,
|
||||||
|
requestTimeout: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const stored = readConfig(h.configPath).mcpServers.srv as {
|
||||||
|
env?: Record<string, string>;
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
// Disk keys + cwd preserved (env value blanked, as always — it lives in
|
||||||
|
// the keychain).
|
||||||
|
expect(stored.env).toEqual({ API_KEY: "" });
|
||||||
|
expect(stored.cwd).toBe("/srv/app");
|
||||||
|
// Keychain value survives: the omit path rehydrates it so the reconcile
|
||||||
|
// doesn't sweep the untouched secret.
|
||||||
|
expect(await h.secretStore.get("srv", envSecretField("API_KEY"))).toBe(
|
||||||
|
"abc-123",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET lifts the stored env / cwd back into the wire config (round-trip)", async () => {
|
||||||
|
writeFileSync(
|
||||||
|
h.configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
srv: {
|
||||||
|
type: "stdio",
|
||||||
|
command: "node",
|
||||||
|
env: { API_KEY: "" },
|
||||||
|
cwd: "/srv/app",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await h.secretStore.set("srv", envSecretField("API_KEY"), "abc-123");
|
||||||
|
const res = await fetch(`${h.baseUrl}/api/servers`);
|
||||||
|
const body = (await res.json()) as MCPConfig;
|
||||||
|
const srv = body.mcpServers.srv as {
|
||||||
|
env?: Record<string, string>;
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
expect(srv.env).toEqual({ API_KEY: "abc-123" });
|
||||||
|
expect(srv.cwd).toBe("/srv/app");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears env / cwd from the config when the settings-only PUT sends an empty list / blank cwd", async () => {
|
||||||
|
writeFileSync(
|
||||||
|
h.configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
srv: {
|
||||||
|
type: "stdio",
|
||||||
|
command: "node",
|
||||||
|
env: { API_KEY: "" },
|
||||||
|
cwd: "/srv/app",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await h.secretStore.set("srv", envSecretField("API_KEY"), "abc-123");
|
||||||
|
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: {
|
||||||
|
headers: [],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
env: [],
|
||||||
|
cwd: "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const stored = readConfig(h.configPath).mcpServers
|
||||||
|
.srv as unknown as Record<string, unknown>;
|
||||||
|
expect(stored).not.toHaveProperty("env");
|
||||||
|
expect(stored).not.toHaveProperty("cwd");
|
||||||
|
// The removed env key reconciles out of the keychain.
|
||||||
|
expect(await h.secretStore.get("srv", envSecretField("API_KEY"))).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops empty-key env rows on a settings-only PUT", async () => {
|
||||||
|
writeFileSync(
|
||||||
|
h.configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: { srv: { type: "stdio", command: "node" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: {
|
||||||
|
headers: [],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
env: [
|
||||||
|
{ key: "KEEP", value: "1" },
|
||||||
|
{ key: "", value: "drop-me" },
|
||||||
|
{ key: " ", value: "drop-me-too" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const stored = readConfig(h.configPath).mcpServers.srv as {
|
||||||
|
env?: Record<string, string>;
|
||||||
|
};
|
||||||
|
expect(Object.keys(stored.env ?? {})).toEqual(["KEEP"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a config-providing PUT owns env/cwd; the preserved settings mirror does not revert it", async () => {
|
||||||
|
// Pre-seed a stdio server with env A=1 (keychain-backed).
|
||||||
|
writeFileSync(
|
||||||
|
h.configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
srv: {
|
||||||
|
type: "stdio",
|
||||||
|
command: "node",
|
||||||
|
env: { A: "" },
|
||||||
|
cwd: "/old",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await h.secretStore.set("srv", envSecretField("A"), "1");
|
||||||
|
// Config-only PUT (settings omitted → preserved). The new config's env/cwd
|
||||||
|
// must win even though the preserved settings mirror still carries the old.
|
||||||
|
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
config: {
|
||||||
|
type: "stdio",
|
||||||
|
command: "node",
|
||||||
|
env: { A: "2" },
|
||||||
|
cwd: "/new",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const stored = readConfig(h.configPath).mcpServers.srv as {
|
||||||
|
env?: Record<string, string>;
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
expect(stored.cwd).toBe("/new");
|
||||||
|
expect(stored.env).toEqual({ A: "" });
|
||||||
|
expect(await h.secretStore.get("srv", envSecretField("A"))).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-array settings.env with 400", async () => {
|
||||||
|
writeFileSync(
|
||||||
|
h.configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: { srv: { type: "stdio", command: "node" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: {
|
||||||
|
headers: [],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
env: "nope",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = (await res.json()) as { error?: string };
|
||||||
|
expect(body.error).toMatch(/settings\.env/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores settings env/cwd for a non-stdio server (no leak onto config)", async () => {
|
||||||
|
writeFileSync(
|
||||||
|
h.configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
srv: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = await fetch(`${h.baseUrl}/api/servers/srv`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: {
|
||||||
|
headers: [],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
env: [{ key: "API_KEY", value: "abc" }],
|
||||||
|
cwd: "/srv/app",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const stored = readConfig(h.configPath).mcpServers
|
||||||
|
.srv as unknown as Record<string, unknown>;
|
||||||
|
expect(stored).not.toHaveProperty("env");
|
||||||
|
expect(stored).not.toHaveProperty("cwd");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("concurrent mutations", () => {
|
describe("concurrent mutations", () => {
|
||||||
it("does not lose updates when many adds fire in parallel (write-lock)", async () => {
|
it("does not lose updates when many adds fire in parallel (write-lock)", async () => {
|
||||||
// Without the in-process mutex, concurrent POSTs would all read the
|
// Without the in-process mutex, concurrent POSTs would all read the
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue