* feat(servers): flatten mcp.json settings to top-level fields (#1358) Eliminate the nested `settings` block introduced in #1352 so each server entry's Inspector-extension fields live as direct keys alongside `type` / `url`, matching the `.mcp.json` shape Claude Code / Cursor / Cline write: - `headers` becomes a `Record<string, string>` on disk (was nested `settings.headers` as a pair-array) - `oauth` becomes a nested `{ clientId?, clientSecret?, scopes? }` object (was flat `settings.oauthClientId` / `oauthClientSecret` / `oauthScopes`) - `metadata`, `connectionTimeout`, `requestTimeout` move to the entry's top level as-is (Inspector-only fields) The in-memory + wire shape is unchanged from #1352: `InspectorServerSettings` keeps pair-array `headers` and flat `oauth*` fields because the form needs them in that shape to drive controlled-component editing. The bidirectional conversion lives in `serverList.ts` (`storedFieldsToInspectorSettings` / `inspectorSettingsToStoredFields`) and is invoked by `mcpConfigToServerEntries` / `serverEntriesToMcpConfig` and by the server route's `buildStoredEntry`. The `PUT /api/servers/:id` wire envelope keeps the preserve/clear/apply semantics from #1353 — the backend splats validated `settings.*` onto top-level disk keys when assembling the next on-disk shape. Hard-cutover migration per the issue's decision 4: files written by the one pre-#1358 v2/main build have a nested `settings` block; we drop the node on read with a one-line warn including the server id. The persisted headers / metadata / timeouts / OAuth credentials are intentionally lost on first read — users re-enter them via the form (or hand-edit the file into the flat shape). v2 has not shipped a stable release with the nested shape, so the blast radius is dogfooders only. The smuggle-guard in `buildStoredEntry` widens from just `settings` to all six Inspector-extension keys (`settings`, `headers`, `metadata`, `connectionTimeout`, `requestTimeout`, `oauth`), so a caller can't bypass `validateSettings` by nesting any of them inside `config`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): address PR review on flatten (#1359) - Single-source the Inspector-extension key list with `satisfies` (finding 1). `INSPECTOR_FIELD_KEY_MAP satisfies Record<keyof StoredInspectorFields, true>` in serverList.ts means a new field on the type forces a compile error here. The set is exported and re-used by the server route's smuggle guard, so adding `proxy` or similar in the future updates all three sites in lockstep. The PUT preserve path now uses a new `stripInspectorFields` helper that derives from the same source instead of hand-destructuring. - Add read-path shape validation in `normalizeMcpServers` (finding 2): `headers: "oops"` / `metadata: 42` / `oauth: []` / non-numeric timeouts are dropped individually with a warn, so a hand-edited malformed file can't put garbage rows into the form. Mirrors the write-side `validateSettings` symmetry. New integration test covers each malformed shape with a partial fixture so a single bad key doesn't take out the whole entry. - Align new warns to the `fileLogger || console.warn` fallback pattern (finding 3) via a small `logWarn` helper, so the legacy-drop and smuggle warns are visible in `npm run dev` where the logger is often unconfigured. - Spec doc tweak: the CLI/TUI out-of-scope note's "writes to `settings.headers`" reference now reads "writes to the entry's top-level `headers` field on disk (post-#1358 flat shape)" (finding 4). Avoids confusion for future readers. - Nits: replace `current.mcpServers[originalId]!` with explicit narrowing; add a comment on the OAuth truthiness drop in `storedFieldsToInspectorSettings` mirroring the write-side comment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(servers): address pass-2 review on flatten (#1359) - Tighten `isNonNegNumber` with `Number.isFinite` so `Infinity` and `NaN` are rejected at the read boundary, matching the write-side intent (finding D). Hand-edited `connectionTimeout: Infinity` would have passed the prior check and propagated to the form. - Add a one-line comment in `normalizeMcpServers` noting that the per-entry object is mutated in place via the `delete` calls — safe for today's callers (freshly parsed JSON, no other reference held) but worth flagging for the next reader (finding A). Skipping findings B (move type guards to module scope — single call site) and C (`isOauthObject` lenient on extra keys — reviewer confirmed this is the intended forward-compat behavior, matching `normalizeServerType`'s unknown-key passthrough). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(servers): fix mutation-comment caller branch label (#1359) The previous comment named the GET handler's "seed branch" as a caller of normalizeMcpServers, but that branch returns DEFAULT_SEED_CONFIG directly without normalizing. The actual second caller is the file-present branch (after parseStore). Correcting the label so a future reader looking for the seed branch caller doesn't waste time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5f9bfdc490
commit
a9df501856
3 changed files with 808 additions and 242 deletions
|
|
@ -147,20 +147,23 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("round-trips a populated settings node through both converters", () => {
|
||||
it("round-trips a populated set of Inspector-extension fields (post-#1358 flat shape)", () => {
|
||||
// Disk shape: top-level `headers` (Record), `metadata` (pair-array),
|
||||
// numeric timeouts, nested `oauth`. Round-trip must preserve the on-
|
||||
// disk shape byte-equivalent so a hand-edited file is stable.
|
||||
const original: MCPConfig = {
|
||||
mcpServers: {
|
||||
gamma: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
settings: {
|
||||
headers: [{ key: "Authorization", value: "Bearer xyz" }],
|
||||
metadata: [{ key: "tenant", value: "acme" }],
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
oauthClientId: "client-abc",
|
||||
oauthClientSecret: "secret-def",
|
||||
oauthScopes: "read:tools",
|
||||
headers: { Authorization: "Bearer xyz" },
|
||||
metadata: [{ key: "tenant", value: "acme" }],
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
oauth: {
|
||||
clientId: "client-abc",
|
||||
clientSecret: "secret-def",
|
||||
scopes: "read:tools",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -169,36 +172,58 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("lifts the settings node from the stored entry onto ServerEntry.settings", () => {
|
||||
it("lifts top-level Inspector-extension fields onto ServerEntry.settings (form shape)", () => {
|
||||
const cfg: MCPConfig = {
|
||||
mcpServers: {
|
||||
alpha: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
settings: {
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
headers: { "X-Tenant": "acme" },
|
||||
oauth: { clientId: "the-client" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const [entry] = mcpConfigToServerEntries(cfg);
|
||||
expect(entry?.settings).toEqual({
|
||||
// Object headers on disk → pair-array headers in memory
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
// Nested oauth on disk → flat oauthClientId in memory
|
||||
oauthClientId: "the-client",
|
||||
});
|
||||
// `settings` should not leak back into config — the SDK transport must
|
||||
// see a clean MCPServerConfig.
|
||||
expect(
|
||||
(entry?.config as unknown as Record<string, unknown>).settings,
|
||||
).toBeUndefined();
|
||||
// None of the Inspector-extension keys leak back into config —
|
||||
// the SDK transport must see a clean MCPServerConfig.
|
||||
const configKeys = Object.keys(
|
||||
entry?.config as unknown as Record<string, unknown>,
|
||||
);
|
||||
expect(configKeys).not.toContain("headers");
|
||||
expect(configKeys).not.toContain("metadata");
|
||||
expect(configKeys).not.toContain("oauth");
|
||||
expect(configKeys).not.toContain("connectionTimeout");
|
||||
expect(configKeys).not.toContain("requestTimeout");
|
||||
});
|
||||
|
||||
it("omits the settings key on disk when ServerEntry.settings is undefined", () => {
|
||||
it("lifts the headers field for a streamable-http entry written by Claude Code", () => {
|
||||
// A pasted-in `.mcp.json` example from the Claude Code docs — top-level
|
||||
// headers, no nested settings node.
|
||||
const cfg: MCPConfig = {
|
||||
mcpServers: {
|
||||
"api-server": {
|
||||
type: "streamable-http",
|
||||
url: "https://api.example.com/mcp",
|
||||
headers: { Authorization: "Bearer the-token" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const [entry] = mcpConfigToServerEntries(cfg);
|
||||
expect(entry?.settings?.headers).toEqual([
|
||||
{ key: "Authorization", value: "Bearer the-token" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits all Inspector-extension keys on disk when ServerEntry.settings is undefined", () => {
|
||||
const entries: ServerEntry[] = [
|
||||
{
|
||||
id: "alpha",
|
||||
|
|
@ -208,7 +233,67 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
},
|
||||
];
|
||||
const cfg = serverEntriesToMcpConfig(entries);
|
||||
expect(cfg.mcpServers.alpha).not.toHaveProperty("settings");
|
||||
const stored = cfg.mcpServers.alpha;
|
||||
expect(stored).not.toHaveProperty("settings");
|
||||
expect(stored).not.toHaveProperty("headers");
|
||||
expect(stored).not.toHaveProperty("metadata");
|
||||
expect(stored).not.toHaveProperty("oauth");
|
||||
expect(stored).not.toHaveProperty("connectionTimeout");
|
||||
expect(stored).not.toHaveProperty("requestTimeout");
|
||||
});
|
||||
|
||||
it("drops empty-key header rows when serializing to disk", () => {
|
||||
// The form leaves a blank row when the user clicks 'Add header' but
|
||||
// hasn't typed yet. Those should not reach disk — the round-trip
|
||||
// would otherwise produce `{ "": "..." }` which neither we nor the
|
||||
// ecosystem can sensibly send as an HTTP header.
|
||||
const entries: ServerEntry[] = [
|
||||
{
|
||||
id: "alpha",
|
||||
name: "alpha",
|
||||
config: { type: "streamable-http", url: "https://x.test" },
|
||||
settings: {
|
||||
headers: [
|
||||
{ key: "X-Tenant", value: "acme" },
|
||||
{ key: "", value: "stub" },
|
||||
{ key: " ", value: "whitespace" },
|
||||
],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
];
|
||||
const stored = serverEntriesToMcpConfig(entries).mcpServers.alpha;
|
||||
expect(stored?.headers).toEqual({ "X-Tenant": "acme" });
|
||||
});
|
||||
|
||||
it("omits zero-valued timeouts and empty oauth fields on serialize", () => {
|
||||
// The form keeps numeric defaults at 0 and empty-string OAuth values.
|
||||
// Round-tripping them onto disk would leave noisy `connectionTimeout: 0`
|
||||
// / `oauth: {}` keys; suppress them so the diff stays minimal for
|
||||
// entries the user never customized.
|
||||
const entries: ServerEntry[] = [
|
||||
{
|
||||
id: "alpha",
|
||||
name: "alpha",
|
||||
config: { type: "streamable-http", url: "https://x.test" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
connection: { status: "disconnected" },
|
||||
},
|
||||
];
|
||||
const stored = serverEntriesToMcpConfig(entries).mcpServers.alpha;
|
||||
expect(stored).not.toHaveProperty("connectionTimeout");
|
||||
expect(stored).not.toHaveProperty("requestTimeout");
|
||||
expect(stored).not.toHaveProperty("oauth");
|
||||
expect(stored).not.toHaveProperty("headers");
|
||||
expect(stored).not.toHaveProperty("metadata");
|
||||
});
|
||||
|
||||
it("preserves insertion order on serialize", () => {
|
||||
|
|
|
|||
|
|
@ -397,14 +397,18 @@ describe("useServers", () => {
|
|||
requestTimeout: 30000,
|
||||
});
|
||||
});
|
||||
const stored = readConfig(h.configPath).mcpServers.alpha as {
|
||||
type?: string;
|
||||
url?: string;
|
||||
settings?: unknown;
|
||||
};
|
||||
const stored = readConfig(h.configPath).mcpServers
|
||||
.alpha as unknown as Record<string, unknown>;
|
||||
// Post-#1358: settings round-trip onto top-level keys on disk, no
|
||||
// nested `settings` wrapper. Each non-zero/non-empty field surfaces
|
||||
// as a sibling of `type` / `url`.
|
||||
expect(stored.type).toBe("streamable-http");
|
||||
expect(stored.url).toBe("https://x.test/mcp");
|
||||
expect(stored.settings).toBeDefined();
|
||||
expect(stored).not.toHaveProperty("settings");
|
||||
expect(stored.headers).toEqual({ "X-Tenant": "acme" });
|
||||
expect(stored.metadata).toEqual([{ key: "trace", value: "abc" }]);
|
||||
expect(stored.connectionTimeout).toBe(5000);
|
||||
expect(stored.requestTimeout).toBe(30000);
|
||||
});
|
||||
|
||||
it("updateServerSettings throws when the target id does not exist (server-side 404)", async () => {
|
||||
|
|
@ -433,12 +437,9 @@ describe("useServers", () => {
|
|||
alpha: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
settings: {
|
||||
headers: [{ key: "X-Keep", value: "yes" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
// Post-#1358 flat shape on disk; the hook lifts `headers` into
|
||||
// the pair-array `settings.headers` it exposes.
|
||||
headers: { "X-Keep": "yes" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -370,13 +370,17 @@ describe("/api/servers routes", () => {
|
|||
});
|
||||
|
||||
describe("settings round-trip", () => {
|
||||
it("persists a settings node on POST", async () => {
|
||||
it("persists Inspector-extension fields at the top level on POST (post-#1358 flat shape)", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "gamma",
|
||||
config: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||
// Wire envelope unchanged from #1353: pair-array headers, flat
|
||||
// oauth* fields. Backend splats these into the flat disk shape:
|
||||
// object headers, nested oauth, plus the inspector-only fields
|
||||
// at the top level.
|
||||
settings: {
|
||||
headers: [{ key: "Authorization", value: "Bearer xyz" }],
|
||||
metadata: [{ key: "tenant", value: "acme" }],
|
||||
|
|
@ -388,20 +392,21 @@ describe("/api/servers routes", () => {
|
|||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.gamma as {
|
||||
settings?: unknown;
|
||||
};
|
||||
expect(stored.settings).toEqual({
|
||||
headers: [{ key: "Authorization", value: "Bearer xyz" }],
|
||||
metadata: [{ key: "tenant", value: "acme" }],
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 60000,
|
||||
oauthClientId: "client-abc",
|
||||
oauthScopes: "read:tools",
|
||||
const stored = readConfig(h.configPath).mcpServers
|
||||
.gamma as unknown as Record<string, unknown>;
|
||||
// Disk shape: flat, no `settings` wrapper, object headers, nested oauth.
|
||||
expect(stored).not.toHaveProperty("settings");
|
||||
expect(stored.headers).toEqual({ Authorization: "Bearer xyz" });
|
||||
expect(stored.metadata).toEqual([{ key: "tenant", value: "acme" }]);
|
||||
expect(stored.connectionTimeout).toBe(30000);
|
||||
expect(stored.requestTimeout).toBe(60000);
|
||||
expect(stored.oauth).toEqual({
|
||||
clientId: "client-abc",
|
||||
scopes: "read:tools",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates a settings node on PUT", async () => {
|
||||
it("updates Inspector-extension fields at the top level on PUT", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
|
|
@ -424,15 +429,14 @@ describe("/api/servers routes", () => {
|
|||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.delta as {
|
||||
settings?: unknown;
|
||||
};
|
||||
expect(stored.settings).toEqual({
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 45000,
|
||||
});
|
||||
const stored = readConfig(h.configPath).mcpServers
|
||||
.delta as unknown as Record<string, unknown>;
|
||||
expect(stored).not.toHaveProperty("settings");
|
||||
expect(stored.headers).toEqual({ "X-Tenant": "acme" });
|
||||
expect(stored.requestTimeout).toBe(45000);
|
||||
// Zero/empty values are suppressed on disk to keep the diff minimal.
|
||||
expect(stored).not.toHaveProperty("metadata");
|
||||
expect(stored).not.toHaveProperty("connectionTimeout");
|
||||
});
|
||||
|
||||
it("rejects a non-object settings field", async () => {
|
||||
|
|
@ -448,7 +452,7 @@ describe("/api/servers routes", () => {
|
|||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("preserves the settings node when PUT omits it (no clobber on config-only save)", async () => {
|
||||
it("preserves Inspector-extension fields when PUT omits settings (no clobber on config-only save)", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
|
|
@ -456,17 +460,13 @@ describe("/api/servers routes", () => {
|
|||
epsilon: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
settings: {
|
||||
headers: [{ key: "X-Keep", value: "yes" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
// Post-#1358 flat shape on disk.
|
||||
headers: { "X-Keep": "yes" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
// PUT without a settings field: the existing settings node must
|
||||
// PUT without a settings field: the existing top-level headers must
|
||||
// survive. A caller updating only the transport config (e.g. the
|
||||
// server config modal) must not silently wipe persisted headers.
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/epsilon`, {
|
||||
|
|
@ -477,16 +477,14 @@ describe("/api/servers routes", () => {
|
|||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.epsilon as {
|
||||
settings?: { headers: { key: string; value: string }[] };
|
||||
};
|
||||
expect(stored.settings).toBeDefined();
|
||||
expect(stored.settings?.headers).toEqual([
|
||||
{ key: "X-Keep", value: "yes" },
|
||||
]);
|
||||
const stored = readConfig(h.configPath).mcpServers
|
||||
.epsilon as unknown as Record<string, unknown>;
|
||||
expect(stored.headers).toEqual({ "X-Keep": "yes" });
|
||||
// URL update must have applied.
|
||||
expect(stored.url).toBe("https://x.test/other");
|
||||
});
|
||||
|
||||
it("clears the settings node when PUT sends settings: null (explicit intent)", async () => {
|
||||
it("clears Inspector-extension fields when PUT sends settings: null (explicit intent)", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
|
|
@ -494,12 +492,9 @@ describe("/api/servers routes", () => {
|
|||
zeta: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
settings: {
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
headers: { "X-Tenant": "acme" },
|
||||
metadata: [{ key: "trace", value: "abc" }],
|
||||
connectionTimeout: 5000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -513,10 +508,14 @@ describe("/api/servers routes", () => {
|
|||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.zeta as {
|
||||
settings?: unknown;
|
||||
};
|
||||
expect(stored.settings).toBeUndefined();
|
||||
const stored = readConfig(h.configPath).mcpServers
|
||||
.zeta as unknown as Record<string, unknown>;
|
||||
// All Inspector-extension fields gone.
|
||||
expect(stored).not.toHaveProperty("headers");
|
||||
expect(stored).not.toHaveProperty("metadata");
|
||||
expect(stored).not.toHaveProperty("connectionTimeout");
|
||||
expect(stored).not.toHaveProperty("requestTimeout");
|
||||
expect(stored).not.toHaveProperty("oauth");
|
||||
});
|
||||
|
||||
it("rejects a malformed settings shape with 400", async () => {
|
||||
|
|
@ -574,16 +573,11 @@ describe("/api/servers routes", () => {
|
|||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.theta as {
|
||||
type?: string;
|
||||
url?: string;
|
||||
settings?: { headers: { key: string; value: string }[] };
|
||||
};
|
||||
const stored = readConfig(h.configPath).mcpServers
|
||||
.theta as unknown as Record<string, unknown>;
|
||||
expect(stored.type).toBe("streamable-http");
|
||||
expect(stored.url).toBe("https://x.test/mcp");
|
||||
expect(stored.settings?.headers).toEqual([
|
||||
{ key: "X-Tenant", value: "acme" },
|
||||
]);
|
||||
expect(stored.headers).toEqual({ "X-Tenant": "acme" });
|
||||
});
|
||||
|
||||
it("rejects a non-object config on PUT with 400", async () => {
|
||||
|
|
@ -605,7 +599,7 @@ describe("/api/servers routes", () => {
|
|||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("validateSettings coerces empty-string OAuth fields to absent (cleared inputs don't read as 'configured')", async () => {
|
||||
it("validateSettings coerces empty-string OAuth fields to absent (cleared inputs don't produce an oauth node on disk)", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -624,13 +618,11 @@ describe("/api/servers routes", () => {
|
|||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers["empty-oauth"] as {
|
||||
settings?: Record<string, unknown>;
|
||||
};
|
||||
expect(stored.settings).toBeDefined();
|
||||
expect(stored.settings).not.toHaveProperty("oauthClientId");
|
||||
expect(stored.settings).not.toHaveProperty("oauthClientSecret");
|
||||
expect(stored.settings).not.toHaveProperty("oauthScopes");
|
||||
const stored = readConfig(h.configPath).mcpServers[
|
||||
"empty-oauth"
|
||||
] as unknown as Record<string, unknown>;
|
||||
// No `oauth` node on disk — every field was empty.
|
||||
expect(stored).not.toHaveProperty("oauth");
|
||||
});
|
||||
|
||||
it("validateSettings drops unknown keys (explicit pick-and-build, not spread)", async () => {
|
||||
|
|
@ -651,24 +643,22 @@ describe("/api/servers routes", () => {
|
|||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers["unknown-keys"] as {
|
||||
settings?: Record<string, unknown>;
|
||||
};
|
||||
expect(stored.settings).toBeDefined();
|
||||
expect(stored.settings).not.toHaveProperty("stowaway");
|
||||
expect(stored.settings).toEqual({
|
||||
headers: [{ key: "X-A", value: "1" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
});
|
||||
const stored = readConfig(h.configPath).mcpServers[
|
||||
"unknown-keys"
|
||||
] as unknown as Record<string, unknown>;
|
||||
expect(stored).not.toHaveProperty("stowaway");
|
||||
expect(stored).not.toHaveProperty("settings");
|
||||
// Only the non-empty/non-zero settings field round-tripped to disk.
|
||||
expect(stored.headers).toEqual({ "X-A": "1" });
|
||||
});
|
||||
|
||||
it("strips smuggled config.settings on POST (validateSettings is the only write path)", async () => {
|
||||
it("strips smuggled Inspector-extension keys from config on POST (envelope is the only write path)", async () => {
|
||||
// `normalizeServerType` spreads unknown keys verbatim. Without the
|
||||
// strip in buildStoredEntry, a body that nests `settings` inside
|
||||
// `config` would land it on the stored entry without ever passing
|
||||
// through `validateSettings`. Pin the strip.
|
||||
// strip in buildStoredEntry, a body that nests Inspector-extension
|
||||
// keys inside `config` would land them on the stored entry without
|
||||
// ever passing through `validateSettings`. Pin the strip for both
|
||||
// the legacy `settings` key and the new flat keys (`headers`,
|
||||
// `metadata`, `connectionTimeout`, `requestTimeout`, `oauth`).
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -678,17 +668,21 @@ describe("/api/servers routes", () => {
|
|||
type: "stdio",
|
||||
command: "node",
|
||||
settings: { bogus: true },
|
||||
headers: { Smuggled: "yes" },
|
||||
oauth: { clientId: "smuggled" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers["smuggle-post"] as {
|
||||
settings?: unknown;
|
||||
};
|
||||
expect(stored.settings).toBeUndefined();
|
||||
const stored = readConfig(h.configPath).mcpServers[
|
||||
"smuggle-post"
|
||||
] as unknown as Record<string, unknown>;
|
||||
expect(stored).not.toHaveProperty("settings");
|
||||
expect(stored).not.toHaveProperty("headers");
|
||||
expect(stored).not.toHaveProperty("oauth");
|
||||
});
|
||||
|
||||
it("strips smuggled config.settings on PUT even when settings:null clears the real node", async () => {
|
||||
it("strips smuggled Inspector-extension keys from config on PUT even when settings:null clears the real fields", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
|
|
@ -696,18 +690,14 @@ describe("/api/servers routes", () => {
|
|||
smuggle: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
settings: {
|
||||
headers: [{ key: "X-Real", value: "yes" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
headers: { "X-Real": "yes" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
// settings: null clears the real settings node; the bogus key nested
|
||||
// under config must not re-attach via the spread inside normalizeServerType.
|
||||
// settings: null clears the real Inspector-extension fields; the bogus
|
||||
// keys nested under config must not re-attach via the spread inside
|
||||
// normalizeServerType.
|
||||
const res = await fetch(`${h.baseUrl}/api/servers/smuggle`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -716,15 +706,18 @@ describe("/api/servers routes", () => {
|
|||
type: "stdio",
|
||||
command: "node",
|
||||
settings: { bogus: true },
|
||||
headers: { Smuggled: "yes" },
|
||||
connectionTimeout: 9999,
|
||||
},
|
||||
settings: null,
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.smuggle as {
|
||||
settings?: unknown;
|
||||
};
|
||||
expect(stored.settings).toBeUndefined();
|
||||
const stored = readConfig(h.configPath).mcpServers
|
||||
.smuggle as unknown as Record<string, unknown>;
|
||||
expect(stored).not.toHaveProperty("settings");
|
||||
expect(stored).not.toHaveProperty("headers");
|
||||
expect(stored).not.toHaveProperty("connectionTimeout");
|
||||
});
|
||||
|
||||
it("rejects a settings array (not an object) with 400", async () => {
|
||||
|
|
@ -742,42 +735,51 @@ describe("/api/servers routes", () => {
|
|||
expect(body.error).toMatch(/object/);
|
||||
});
|
||||
|
||||
it("strips a malformed settings node on read (lenient-on-read; preserve-on-PUT cannot propagate it)", async () => {
|
||||
// Hand-edited mcp.json with a malformed `settings.headers` (string
|
||||
// instead of array). The write-path validator would reject it, but a
|
||||
// user could write it directly. GET should surface the entry with
|
||||
// settings dropped, and a subsequent config-only PUT should not
|
||||
// re-attach the broken node via the preserve branch.
|
||||
it("drops a legacy nested `settings` node on read (hard cutover per #1358 decision 4)", async () => {
|
||||
// A user upgrading from the one-#1352 v2/main build has a file with a
|
||||
// nested `settings` block. Per the hard-cutover decision the persisted
|
||||
// headers / metadata / timeouts / OAuth credentials are intentionally
|
||||
// lost on first read — users re-enter them through the form or hand-
|
||||
// edit the file into the flat shape. GET surfaces the entry with
|
||||
// settings dropped, and a subsequent config-only PUT does not
|
||||
// re-attach the legacy node via the preserve branch.
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
broken: {
|
||||
legacy: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
settings: {
|
||||
headers: "oops",
|
||||
headers: [{ key: "X-Tenant", value: "acme" }],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
connectionTimeout: 30000,
|
||||
requestTimeout: 0,
|
||||
oauthClientId: "client-abc",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// GET surfaces the entry without the malformed settings.
|
||||
// GET surfaces the entry with the legacy settings dropped — no flat
|
||||
// fields lifted in either (this is hard cutover, not migration).
|
||||
const getRes = await fetch(`${h.baseUrl}/api/servers`);
|
||||
expect(getRes.status).toBe(200);
|
||||
const getBody = (await getRes.json()) as {
|
||||
mcpServers: Record<string, { settings?: unknown }>;
|
||||
mcpServers: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
expect(getBody.mcpServers.broken).toBeDefined();
|
||||
expect(getBody.mcpServers.broken!.settings).toBeUndefined();
|
||||
const fetched = getBody.mcpServers.legacy!;
|
||||
expect(fetched).toBeDefined();
|
||||
expect(fetched).not.toHaveProperty("settings");
|
||||
expect(fetched).not.toHaveProperty("headers");
|
||||
expect(fetched).not.toHaveProperty("metadata");
|
||||
expect(fetched).not.toHaveProperty("connectionTimeout");
|
||||
expect(fetched).not.toHaveProperty("oauth");
|
||||
|
||||
// PUT without settings preserves the (now-stripped) absence, not the
|
||||
// original malformed node.
|
||||
const putRes = await fetch(`${h.baseUrl}/api/servers/broken`, {
|
||||
// PUT without settings preserves the (now-cleared) absence; the next
|
||||
// save persists the flat shape with no `settings` field.
|
||||
const putRes = await fetch(`${h.baseUrl}/api/servers/legacy`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
|
|
@ -785,10 +787,108 @@ describe("/api/servers routes", () => {
|
|||
}),
|
||||
});
|
||||
expect(putRes.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.broken as {
|
||||
settings?: unknown;
|
||||
const stored = readConfig(h.configPath).mcpServers
|
||||
.legacy as unknown as Record<string, unknown>;
|
||||
expect(stored).not.toHaveProperty("settings");
|
||||
expect(stored).not.toHaveProperty("headers");
|
||||
});
|
||||
|
||||
it("drops individually malformed Inspector-extension fields on read (read-path shape validation)", async () => {
|
||||
// A hand-edited mcp.json with a mix of valid and malformed
|
||||
// Inspector-extension fields. `normalizeMcpServers` drops each bad
|
||||
// field independently with a warn — so one wrong key doesn't take
|
||||
// out the whole entry, and garbage values can't reach the form via
|
||||
// the disk → memory converter.
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
bad: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
// Good: should round-trip
|
||||
headers: { "X-Keep": "yes" },
|
||||
// Bad: string instead of pair-array → drop
|
||||
metadata: "oops",
|
||||
// Bad: non-number timeout → drop
|
||||
connectionTimeout: "30s",
|
||||
// Good: valid number
|
||||
requestTimeout: 60000,
|
||||
// Bad: array instead of object → drop
|
||||
oauth: ["not", "an", "object"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const getRes = await fetch(`${h.baseUrl}/api/servers`);
|
||||
expect(getRes.status).toBe(200);
|
||||
const getBody = (await getRes.json()) as {
|
||||
mcpServers: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
expect(stored.settings).toBeUndefined();
|
||||
const fetched = getBody.mcpServers.bad!;
|
||||
// Good fields survive.
|
||||
expect(fetched.headers).toEqual({ "X-Keep": "yes" });
|
||||
expect(fetched.requestTimeout).toBe(60000);
|
||||
// Bad fields are dropped.
|
||||
expect(fetched).not.toHaveProperty("metadata");
|
||||
expect(fetched).not.toHaveProperty("connectionTimeout");
|
||||
expect(fetched).not.toHaveProperty("oauth");
|
||||
});
|
||||
|
||||
it("loads a hand-edited file with top-level Claude Code-style `headers` (interop with `.mcp.json`)", async () => {
|
||||
// A user pastes a server entry copied from the Claude Code docs:
|
||||
// top-level `headers: { ... }`, no settings wrapper. GET should
|
||||
// surface the entry verbatim (flat disk shape) and a subsequent
|
||||
// settings-only PUT must preserve the headers when omitted.
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
"api-server": {
|
||||
type: "streamable-http",
|
||||
url: "https://api.example.com/mcp",
|
||||
headers: { Authorization: "Bearer the-token" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const getRes = await fetch(`${h.baseUrl}/api/servers`);
|
||||
expect(getRes.status).toBe(200);
|
||||
const getBody = (await getRes.json()) as {
|
||||
mcpServers: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
expect(getBody.mcpServers["api-server"]!.headers).toEqual({
|
||||
Authorization: "Bearer the-token",
|
||||
});
|
||||
|
||||
// Settings-only PUT (no `config`) preserves the existing url AND the
|
||||
// existing headers per the preserve-on-omit branch.
|
||||
const putRes = await fetch(`${h.baseUrl}/api/servers/api-server`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
settings: {
|
||||
headers: [
|
||||
{ key: "Authorization", value: "Bearer the-token" },
|
||||
{ key: "X-Tenant", value: "acme" },
|
||||
],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(putRes.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers[
|
||||
"api-server"
|
||||
] as unknown as Record<string, unknown>;
|
||||
expect(stored.url).toBe("https://api.example.com/mcp");
|
||||
expect(stored.headers).toEqual({
|
||||
Authorization: "Bearer the-token",
|
||||
"X-Tenant": "acme",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue