feat(servers): flatten mcp.json settings to top-level fields (#1358) (#1359)

* 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:
Cliff Hall 2026-05-25 15:52:37 -04:00 • committed by GitHub
commit a9df501856
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 808 additions and 242 deletions

View file

@ -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", () => {

View file

@ -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" },
},
},
}),