* 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
13 changed files with 1116 additions and 18 deletions
|
|
@ -338,6 +338,7 @@ describe("RemoteClientTransport", () => {
|
|||
});
|
||||
const settings = {
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -2,13 +2,17 @@ import { describe, it, expect } from "vitest";
|
|||
import {
|
||||
cleanRoots,
|
||||
DEFAULT_SEED_CONFIG,
|
||||
envPairsToRecord,
|
||||
envRecordToPairs,
|
||||
expectedSecretFields,
|
||||
extractSecretsFromStored,
|
||||
inspectorSettingsToStoredFields,
|
||||
mcpConfigToServerEntries,
|
||||
mergeSecretsIntoStored,
|
||||
normalizeServerType,
|
||||
serverEntriesToMcpConfig,
|
||||
serializeMcpConfig,
|
||||
storedFieldsToInspectorSettings,
|
||||
} from "@inspector/core/mcp/serverList.js";
|
||||
import {
|
||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||
|
|
@ -314,6 +318,8 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
expect(entry?.settings).toEqual({
|
||||
// Object headers on disk → pair-array headers in memory
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
// Non-stdio server → empty env mirror in memory (for the form)
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
@ -393,6 +399,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
{ key: "", value: "stub" },
|
||||
{ key: " ", value: "whitespace" },
|
||||
],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
@ -419,6 +426,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
config: { type: "streamable-http", url: "https://x.test" },
|
||||
settings: {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
@ -447,6 +455,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
config: { type: "stdio", command: "node" },
|
||||
settings: {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
@ -485,6 +494,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
config: { type: "stdio", command: "node" },
|
||||
settings: {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
@ -511,6 +521,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
config: { type: "stdio", command: "node" },
|
||||
settings: {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
@ -777,3 +788,130 @@ describe("expectedSecretFields", () => {
|
|||
).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 = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ function template(name: string): ResourceTemplate {
|
|||
|
||||
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ function resource(uri: string): Resource {
|
|||
|
||||
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ function tool(name: string): Tool {
|
|||
|
||||
const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -411,6 +411,7 @@ describe("useServers", () => {
|
|||
await act(async () => {
|
||||
await result.current.updateServerSettings("alpha", {
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
env: [],
|
||||
metadata: [{ key: "trace", value: "abc" }],
|
||||
connectionTimeout: 5000,
|
||||
requestTimeout: 30000,
|
||||
|
|
@ -423,6 +424,7 @@ describe("useServers", () => {
|
|||
await waitFor(() => {
|
||||
expect(result.current.servers[0]?.settings).toEqual({
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
env: [],
|
||||
metadata: [{ key: "trace", value: "abc" }],
|
||||
connectionTimeout: 5000,
|
||||
requestTimeout: 30000,
|
||||
|
|
@ -457,6 +459,7 @@ describe("useServers", () => {
|
|||
act(async () => {
|
||||
await result.current.updateServerSettings("nonexistent", {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
@ -506,6 +509,7 @@ describe("useServers", () => {
|
|||
});
|
||||
expect(result.current.servers[0]?.settings).toEqual({
|
||||
headers: [{ key: "X-Keep", value: "yes" }],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -22,13 +22,14 @@ describe("parseMcpServersConfig", () => {
|
|||
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({
|
||||
mcpServers: {
|
||||
s: {
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
env: { API_KEY: "abc" },
|
||||
cwd: "/srv/app",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -37,6 +38,7 @@ describe("parseMcpServersConfig", () => {
|
|||
expect(entry.type).toBe("stdio");
|
||||
if (entry.type === "stdio") {
|
||||
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.
|
||||
if (opt.baseConfig.type === "stdio") {
|
||||
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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ describe("InspectorClient", () => {
|
|||
environment: { transport: fakeFactory },
|
||||
serverSettings: {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 50,
|
||||
requestTimeout: 0,
|
||||
|
|
@ -660,6 +661,7 @@ describe("InspectorClient", () => {
|
|||
it("getServerSettings returns the constructor settings; setServerSettings replaces them live (#1444)", () => {
|
||||
const initial = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
|
|
@ -213,6 +213,7 @@ describe("Transport", () => {
|
|||
{ key: "X-Trace", value: "abc123" },
|
||||
{ key: "", value: "ignored-empty-key" },
|
||||
],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
@ -267,6 +268,7 @@ describe("Transport", () => {
|
|||
const config: MCPServerConfig = { type: "sse", url: server.url };
|
||||
const settings: InspectorServerSettings = {
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
@ -311,6 +313,7 @@ describe("Transport", () => {
|
|||
};
|
||||
const settings: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 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", () => {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -407,6 +407,7 @@ describe("Remote transport e2e", () => {
|
|||
{ key: "X-Tenant", value: "acme" },
|
||||
{ key: "X-Trace", value: "abc123" },
|
||||
],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
|
|
|
|||
Loading…
Reference in a new issue