Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions clients/web/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3168,6 +3168,94 @@ describe("App paginated list pagination toggle (#1721)", () => {
expect(fetchLogOptions?.maxFetchRequests).toBe(42);
});

it("carries a landed stdio env/cwd onto the config it connects with (#2096)", async () => {
// The sibling case above covers the client *options*, which are derived
// from the settings. `env` and `cwd` are the two fields the modal edits as
// settings while the transport reads them off the **config** — and the
// config came straight off the same frozen `servers` entry. So a save that
// landed while list reads were failing kept spawning the child process with
// the pre-save environment, while the modal (re-seeded from the tracker
// since #2089) showed the new one. Credentials live here, so the symptom is
// the inspected server failing to authorize rather than a lost edit.
const user = userEvent.setup();
renderWithMantine(<App />);

const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
await act(async () => {
await draftOptions.onPersist("A", {
...settingsWithRoots([]),
env: [{ key: "FOO", value: "after" }],
cwd: "/tmp/after",
});
});

// `servers` still reports `{ type: "stdio", command: "node" }` with no
// settings node at all — what a failed reload leaves behind.
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
expect(vi.mocked(McpIndex.InspectorClient).mock.calls.at(-1)?.[0]).toEqual({
type: "stdio",
command: "node",
env: { FOO: "after" },
cwd: "/tmp/after",
});
});

it("clears a stdio env/cwd the last write removed (#2096)", async () => {
// Clearing has to travel too, and it is the direction a naive merge gets
// wrong: an empty env list and a blank cwd mean "remove the field", which
// is how the modal deletes a value. Spreading the settings over the config
// would leave the stale one in place instead.
const user = userEvent.setup();
const previousUseServers = vi.mocked(useServers).getMockImplementation();
try {
vi.mocked(useServers).mockReturnValue({
servers: [
{
...SERVER_A,
config: {
type: "stdio",
command: "node",
env: { FOO: "before" },
cwd: "/tmp/before",
},
} as ServerEntry,
],
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: vi.fn(),
updateServer: vi.fn(),
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn(),
reorderServers: vi.fn(),
importSource: vi.fn().mockResolvedValue({ servers: {} }),
});
renderWithMantine(<App />);

const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
await act(async () => {
await draftOptions.onPersist("A", {
...settingsWithRoots([]),
env: [],
cwd: "",
});
});

await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
expect(
vi.mocked(McpIndex.InspectorClient).mock.calls.at(-1)?.[0],
).toEqual({ type: "stdio", command: "node" });
} finally {
if (previousUseServers) {
vi.mocked(useServers).mockImplementation(previousUseServers);
}
}
});

it("lets a successful list read supersede a concrete rollback override (#2089)", async () => {
// A rollback sets the override to a value rather than clearing it, so the
// effect that drops it cannot key on the persisted boolean alone: if a
Expand Down
15 changes: 14 additions & 1 deletion clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managed
import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js";
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
import {
applyStdioSettingsToConfig,
cleanRoots,
oauthAuthorizationParamsFromSettings,
oauthEndpointOverridesFromSettings,
Expand Down Expand Up @@ -2650,7 +2651,19 @@ function App() {
...(activeCimdUrl && { clientMetadataUrl: activeCimdUrl }),
}
: undefined;
const client = new InspectorClient(server.config, {
// The stdio `env` / `cwd` are edited as *settings* but stored on — and
// read by the transport from — *config*, so the tracker's account of the
// former has to be carried onto the latter. `server.config` comes off the
// same frozen `servers` entry `server.settings` does, so without this a
// save that landed while list reads were failing spawns the child process
// with the pre-save environment while the modal, re-seeded from the
// tracker, shows the new one (#2096). Same mapping the PUT route applies
// when persisting it, from the same helper.
const effectiveConfig = applyStdioSettingsToConfig(
server.config,
savedSettings,
);
const client = new InspectorClient(effectiveConfig, {
environment,
// The Tasks tab needs the receiver-task pipeline; the
// requestor-task list comes from the client's task store.
Expand Down
125 changes: 125 additions & 0 deletions clients/web/src/test/core/mcp/serverList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@ import {
mergeSecretsIntoStored,
normalizeServerType,
normalizeStoredMetadata,
applyStdioSettingsToConfig,
stdioConfigFieldsFromSettings,
oauthAuthorizationParamsFromSettings,
oauthEndpointOverridesFromSettings,
serverEntriesToMcpConfig,
serializeMcpConfig,
storedFieldsToInspectorSettings,
} from "@inspector/core/mcp/serverList.js";
import type {
InspectorServerSettings,
MCPServerConfig,
} from "@inspector/core/mcp/types.js";
import {
SECRET_FIELD_OAUTH_CLIENT_SECRET,
envSecretField,
Expand Down Expand Up @@ -1771,3 +1777,122 @@ describe("cleanAuthorizationParams", () => {
]);
});
});

describe("stdio env/cwd settings → config mapping (#2096)", () => {
// `env` and `cwd` are edited as settings but read by the transport off the
// config, so both sides of the wire have to derive one from the other. This
// is the single mapping they share — the PUT write-through persists it, the
// web client applies it when constructing an InspectorClient.
const settings = (
over: Partial<InspectorServerSettings> = {},
): InspectorServerSettings => ({
headers: [],
env: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 1000,
roots: [],
...over,
});

describe("stdioConfigFieldsFromSettings", () => {
it("collapses the pair rows into a record and trims the cwd", () => {
expect(
stdioConfigFieldsFromSettings(
settings({
env: [
{ key: "FOO", value: "after" },
{ key: "BAR", value: "" },
],
cwd: " /tmp/after ",
}),
),
).toEqual({ env: { FOO: "after", BAR: "" }, cwd: "/tmp/after" });
});

it("reports an empty list and a blank cwd as unset, i.e. cleared", () => {
// The modal deletes a value by emptying it, so "no rows" cannot mean
// "leave it alone" — it has to remove the field.
expect(
stdioConfigFieldsFromSettings(settings({ env: [], cwd: " " })),
).toEqual({ env: undefined, cwd: undefined });
// A row the user started and left blank is dropped, so it does not
// materialize an env consisting of one nameless variable.
expect(
stdioConfigFieldsFromSettings(
settings({ env: [{ key: " ", value: "x" }] }),
).env,
).toBeUndefined();
});
});

describe("applyStdioSettingsToConfig", () => {
const stdio: MCPServerConfig = {
type: "stdio",
command: "node",
args: ["server.js"],
env: { FOO: "before" },
cwd: "/tmp/before",
};

it("overwrites env/cwd and leaves the rest of the config alone", () => {
expect(
applyStdioSettingsToConfig(
stdio,
settings({
env: [{ key: "FOO", value: "after" }],
cwd: "/tmp/after",
}),
),
).toEqual({
type: "stdio",
command: "node",
args: ["server.js"],
env: { FOO: "after" },
cwd: "/tmp/after",
});
});

it("removes the fields the settings cleared", () => {
expect(applyStdioSettingsToConfig(stdio, settings())).toEqual({
type: "stdio",
command: "node",
args: ["server.js"],
});
});

it("does not mutate the config it was given", () => {
const before = { ...stdio };
applyStdioSettingsToConfig(stdio, settings());
expect(stdio).toEqual(before);
});

it("treats a typeless entry as stdio, matching the on-disk default", () => {
// An `mcp.json` entry written without a `type` key is stdio (the Claude
// Desktop convention), and the modal edits its env like any other.
const applied = applyStdioSettingsToConfig(
{ command: "node" } as MCPServerConfig,
settings({ env: [{ key: "FOO", value: "after" }] }),
);
expect(applied).toEqual({ command: "node", env: { FOO: "after" } });
});

it("returns a non-stdio config untouched", () => {
// An HTTP server carries neither field, matching the modal's stdio-only
// UI — applying an empty mirror to it must not invent one.
const http: MCPServerConfig = {
type: "streamable-http",
url: "https://mcp.example.com/mcp",
};
expect(applyStdioSettingsToConfig(http, settings())).toBe(http);
});

it("returns the config untouched when there are no settings", () => {
// A server with no settings node has nothing to apply; treating that as
// an empty mirror would clear an env the file does hold.
expect(applyStdioSettingsToConfig(stdio, undefined)).toBe(stdio);
});
});
});
10 changes: 6 additions & 4 deletions core/mcp/remote/node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,14 @@ import type {
} from "../../types.js";
import {
DEFAULT_SEED_CONFIG,
envPairsToRecord,
expectedSecretFields,
extractSecretsFromStored,
INSPECTOR_FIELD_KEYS,
inspectorSettingsToStoredFields,
isProtocolEra,
mergeSecretsIntoStored,
normalizeServerType,
stdioConfigFieldsFromSettings,
storedFieldsToInspectorSettings,
stripInspectorFields,
} from "../../serverList.js";
Expand Down Expand Up @@ -1653,16 +1653,18 @@ export function createRemoteApp(
): void => {
if (!(entry.type === "stdio" || entry.type === undefined)) return;
const stdio = entry as StdioServerConfig & StoredMCPServer;
// Shared with the web client's connect-time application of the same
// mapping (#2096), so the environment a save persists and the one the
// child process is spawned with cannot be derived differently.
const { env, cwd } = stdioConfigFieldsFromSettings(settings);
if (provided.env) {
const env = envPairsToRecord(settings.env);
if (Object.keys(env).length > 0) {
if (env) {
stdio.env = env;
} else {
delete stdio.env;
}
}
if (provided.cwd) {
const cwd = settings.cwd?.trim();
if (cwd) {
stdio.cwd = cwd;
} else {
Expand Down
56 changes: 56 additions & 0 deletions core/mcp/serverList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,62 @@ export function envPairsToRecord(
return out;
}

/**
* The stdio `env` / `cwd` **config** fields implied by a settings mirror.
* `undefined` means "the field is not set" — i.e. remove it — which is how a
* user clears a value through the Server Settings modal (empty env list, blank
* cwd).
*
* `env` and `cwd` are the two fields the modal edits as *settings* while the
* transport reads them off *config* (see `storedFieldsToInspectorSettings`), so
* every consumer that has settings and needs a config has to perform this same
* mapping. It lives here, once, because the two consumers are on opposite sides
* of the wire — the `/api/servers` PUT write-through persists it, and the web
* client applies it when constructing an `InspectorClient` (#2096) — and a
* disagreement between them is invisible until a spawned child process gets an
* environment the file does not describe.
*/
export function stdioConfigFieldsFromSettings(
settings: InspectorServerSettings,
): { env: Record<string, string> | undefined; cwd: string | undefined } {
const env = envPairsToRecord(settings.env);
const cwd = settings.cwd?.trim();
return {
env: Object.keys(env).length > 0 ? env : undefined,
cwd: cwd ? cwd : undefined,
};
}

/**
* A copy of `config` with the stdio `env` / `cwd` taken from `settings`.
*
* Used where a caller holds a settings value it trusts more than the config it
* was read alongside — the web client's connect path, which resolves settings
* through the last-persisted-write tracker while `config` still comes off a
* `servers` entry that stops advancing once a list read fails (#2089). Without
* this the two disagree and the child process is spawned with the *pre-save*
* environment even though the save reached disk (#2096).
*
* Non-stdio configs are returned untouched — they carry neither field, matching
* the modal's stdio-only UI. Absent settings likewise: a server with no settings
* node has nothing to apply, and treating that as an empty mirror would clear a
* config `env` the file does hold.
*/
export function applyStdioSettingsToConfig(
config: MCPServerConfig,
settings: InspectorServerSettings | undefined,
): MCPServerConfig {
if (!settings) return config;
if (!(config.type === "stdio" || config.type === undefined)) return config;
const { env, cwd } = stdioConfigFieldsFromSettings(settings);
const next: StdioServerConfig = { ...(config as StdioServerConfig) };
if (env) next.env = env;
else delete next.env;
if (cwd) next.cwd = cwd;
else delete next.cwd;
return next;
}

/**
* Read a server's `metadata` off disk into the in-memory `RequestMetadata`
* object, accepting both the current object shape and the pre-#1910
Expand Down