Skip to content

Commit 3ce3420

Browse files
cliffhallclaude
andcommitted
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>
1 parent aa981db commit 3ce3420

4 files changed

Lines changed: 227 additions & 54 deletions

File tree

clients/web/src/test/integration/mcp/remote/servers-route.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,49 @@ describe("/api/servers routes", () => {
793793
expect(stored).not.toHaveProperty("headers");
794794
});
795795

796+
it("drops individually malformed Inspector-extension fields on read (read-path shape validation)", async () => {
797+
// A hand-edited mcp.json with a mix of valid and malformed
798+
// Inspector-extension fields. `normalizeMcpServers` drops each bad
799+
// field independently with a warn — so one wrong key doesn't take
800+
// out the whole entry, and garbage values can't reach the form via
801+
// the disk → memory converter.
802+
writeFileSync(
803+
h.configPath,
804+
JSON.stringify({
805+
mcpServers: {
806+
bad: {
807+
type: "streamable-http",
808+
url: "https://x.test/mcp",
809+
// Good: should round-trip
810+
headers: { "X-Keep": "yes" },
811+
// Bad: string instead of pair-array → drop
812+
metadata: "oops",
813+
// Bad: non-number timeout → drop
814+
connectionTimeout: "30s",
815+
// Good: valid number
816+
requestTimeout: 60000,
817+
// Bad: array instead of object → drop
818+
oauth: ["not", "an", "object"],
819+
},
820+
},
821+
}),
822+
);
823+
824+
const getRes = await fetch(`${h.baseUrl}/api/servers`);
825+
expect(getRes.status).toBe(200);
826+
const getBody = (await getRes.json()) as {
827+
mcpServers: Record<string, Record<string, unknown>>;
828+
};
829+
const fetched = getBody.mcpServers.bad!;
830+
// Good fields survive.
831+
expect(fetched.headers).toEqual({ "X-Keep": "yes" });
832+
expect(fetched.requestTimeout).toBe(60000);
833+
// Bad fields are dropped.
834+
expect(fetched).not.toHaveProperty("metadata");
835+
expect(fetched).not.toHaveProperty("connectionTimeout");
836+
expect(fetched).not.toHaveProperty("oauth");
837+
});
838+
796839
it("loads a hand-edited file with top-level Claude Code-style `headers` (interop with `.mcp.json`)", async () => {
797840
// A user pastes a server entry copied from the Claude Code docs:
798841
// top-level `headers: { ... }`, no settings wrapper. GET should

core/mcp/remote/node/server.ts

Lines changed: 135 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ import type {
3333
} from "../../types.js";
3434
import {
3535
DEFAULT_SEED_CONFIG,
36+
INSPECTOR_FIELD_KEYS,
3637
inspectorSettingsToStoredFields,
3738
normalizeServerType,
3839
storedFieldsToInspectorSettings,
40+
stripInspectorFields,
3941
} from "../../serverList.js";
4042
import { RemoteSession } from "./remote-session.js";
4143
import { createTokenAuthProvider } from "./tokenAuthProvider.js";
@@ -792,40 +794,128 @@ export function createRemoteApp(
792794

793795
// --- /api/servers (server list backed by mcp.json) ---
794796

797+
// Match handleWatcherError's pattern: log to the configured logger if
798+
// present, fall back to console.warn for visibility in `npm run dev`
799+
// where no logger is wired up. Both gates below feed user-visible
800+
// signals (legacy on-disk shape, client bug smuggling fields), so a
801+
// silent drop is worse than a console line.
802+
const logWarn = (bindings: Record<string, unknown>, msg: string): void => {
803+
if (fileLogger) {
804+
fileLogger.warn(bindings, msg);
805+
} else {
806+
console.warn("[mcp.json]", msg, bindings);
807+
}
808+
};
809+
795810
// Defensive normalize so editor-edited files with type:"http" or missing
796811
// type round-trip into the canonical form on every read. Inspector-
797812
// extension fields (headers / metadata / connectionTimeout / requestTimeout
798-
// / oauth) sit at the top level of each entry post-#1358; `normalizeServerType`
799-
// spreads them through unchanged. Legacy `settings` nodes written by the
800-
// pre-#1358 build (one #1352 release on v2/main that never shipped stable)
801-
// are dropped with a warn — those persisted headers / metadata / timeouts
802-
// / OAuth credentials are intentionally lost on first read (hard cutover
803-
// per #1358 decision 4). Users re-enter via the form or hand-edit into the
813+
// / oauth) sit at the top level of each entry post-#1358; this function
814+
// is the read-side gate — it validates each field's shape and drops
815+
// anything malformed with a logged warn, so a hand-edited file with
816+
// `headers: "oops"` can't put garbage rows into the form.
817+
//
818+
// Legacy `settings` nodes written by the pre-#1358 build (one #1352
819+
// release on v2/main that never shipped stable) are dropped with a
820+
// warn — those persisted headers / metadata / timeouts / OAuth
821+
// credentials are intentionally lost on first read (hard cutover per
822+
// #1358 decision 4). Users re-enter via the form or hand-edit into the
804823
// flat shape.
824+
const isStringRecord = (v: unknown): v is Record<string, string> => {
825+
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
826+
for (const val of Object.values(v as Record<string, unknown>)) {
827+
if (typeof val !== "string") return false;
828+
}
829+
return true;
830+
};
831+
const isKvArray = (v: unknown): v is { key: string; value: string }[] => {
832+
if (!Array.isArray(v)) return false;
833+
return v.every(
834+
(e) =>
835+
e !== null &&
836+
typeof e === "object" &&
837+
typeof (e as Record<string, unknown>).key === "string" &&
838+
typeof (e as Record<string, unknown>).value === "string",
839+
);
840+
};
841+
const isOauthObject = (
842+
v: unknown,
843+
): v is { clientId?: string; clientSecret?: string; scopes?: string } => {
844+
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
845+
const o = v as Record<string, unknown>;
846+
for (const k of ["clientId", "clientSecret", "scopes"] as const) {
847+
if (o[k] !== undefined && typeof o[k] !== "string") return false;
848+
}
849+
return true;
850+
};
851+
const isNonNegNumber = (v: unknown): v is number =>
852+
typeof v === "number" && v >= 0;
853+
805854
const normalizeMcpServers = (
806855
raw: unknown,
807856
): Record<string, StoredMCPServer> => {
808857
if (!raw || typeof raw !== "object") return {};
809858
const out: Record<string, StoredMCPServer> = {};
810859
for (const [id, val] of Object.entries(raw as Record<string, unknown>)) {
811860
if (!val || typeof val !== "object") continue;
812-
// Strip a legacy nested `settings` node before normalizeServerType
813-
// would spread it through. The keys that LIVE flat on disk
814-
// (`headers` / `metadata` / `oauth` / ...) are not touched here —
815-
// they pass through verbatim and the disk → memory converter
816-
// (`storedFieldsToInspectorSettings`) is the read-side gate.
817861
const valObj = val as Record<string, unknown>;
862+
863+
// Strip a legacy nested `settings` node before normalizeServerType
864+
// would spread it through. Hard cutover per decision 4.
818865
if ("settings" in valObj) {
819-
fileLogger?.warn(
866+
logWarn(
820867
{ route: "/api/servers", id, droppedKey: "settings" },
821868
"Dropping legacy `settings` node from mcp.json entry — fields now live at the top level. Re-enter via the settings form or hand-edit the file into the flat shape.",
822869
);
823-
const { settings: _legacy, ...rest } = valObj;
824-
out[id] = normalizeServerType(
825-
rest as Record<string, unknown> & { type?: string },
826-
) as StoredMCPServer;
827-
continue;
870+
delete valObj.settings;
828871
}
872+
873+
// Per-field shape validation on the Inspector-extension keys. The
874+
// write path (validateSettings) is symmetric — same checks. Drop
875+
// bad shapes individually so a single malformed key doesn't take
876+
// out the rest of the entry; log so the user can fix the file.
877+
if ("headers" in valObj && !isStringRecord(valObj.headers)) {
878+
logWarn(
879+
{ route: "/api/servers", id, droppedKey: "headers" },
880+
"Dropping malformed `headers` field — expected `Record<string, string>`.",
881+
);
882+
delete valObj.headers;
883+
}
884+
if ("metadata" in valObj && !isKvArray(valObj.metadata)) {
885+
logWarn(
886+
{ route: "/api/servers", id, droppedKey: "metadata" },
887+
"Dropping malformed `metadata` field — expected `Array<{ key: string, value: string }>`.",
888+
);
889+
delete valObj.metadata;
890+
}
891+
if (
892+
"connectionTimeout" in valObj &&
893+
!isNonNegNumber(valObj.connectionTimeout)
894+
) {
895+
logWarn(
896+
{ route: "/api/servers", id, droppedKey: "connectionTimeout" },
897+
"Dropping malformed `connectionTimeout` field — expected non-negative number.",
898+
);
899+
delete valObj.connectionTimeout;
900+
}
901+
if (
902+
"requestTimeout" in valObj &&
903+
!isNonNegNumber(valObj.requestTimeout)
904+
) {
905+
logWarn(
906+
{ route: "/api/servers", id, droppedKey: "requestTimeout" },
907+
"Dropping malformed `requestTimeout` field — expected non-negative number.",
908+
);
909+
delete valObj.requestTimeout;
910+
}
911+
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
912+
logWarn(
913+
{ route: "/api/servers", id, droppedKey: "oauth" },
914+
"Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes? }`.",
915+
);
916+
delete valObj.oauth;
917+
}
918+
829919
out[id] = normalizeServerType(
830920
valObj as Record<string, unknown> & { type?: string },
831921
) as StoredMCPServer;
@@ -849,14 +939,16 @@ export function createRemoteApp(
849939
// `id` is threaded through purely so the warning correlates to a specific
850940
// entry; the route ultimately reaches here via POST or PUT and both know
851941
// the target id at call time.
852-
const SMUGGLE_GUARDED_KEYS = [
942+
//
943+
// The set of guarded keys is the source-of-truth `INSPECTOR_FIELD_KEYS`
944+
// plus the legacy `"settings"` wrapper key. Adding a new Inspector-
945+
// extension field to `StoredMCPServer` propagates through the
946+
// `satisfies` check in `serverList.ts` and updates this guard
947+
// automatically; nothing to remember to update here.
948+
const SMUGGLE_GUARDED_KEYS: ReadonlySet<string> = new Set<string>([
949+
...INSPECTOR_FIELD_KEYS,
853950
"settings",
854-
"headers",
855-
"metadata",
856-
"connectionTimeout",
857-
"requestTimeout",
858-
"oauth",
859-
] as const;
951+
]);
860952
const buildStoredEntry = (
861953
id: string,
862954
config: unknown,
@@ -873,14 +965,14 @@ export function createRemoteApp(
873965
const smuggled: string[] = [];
874966
const configOnly: Record<string, unknown> = {};
875967
for (const [k, v] of Object.entries(configObj)) {
876-
if ((SMUGGLE_GUARDED_KEYS as readonly string[]).includes(k)) {
968+
if (SMUGGLE_GUARDED_KEYS.has(k)) {
877969
smuggled.push(k);
878970
continue;
879971
}
880972
configOnly[k] = v;
881973
}
882974
if (smuggled.length > 0) {
883-
fileLogger?.warn(
975+
logWarn(
884976
{ route: "/api/servers", id, smuggledKeys: smuggled },
885977
"Stripping Inspector-extension keys from request body's `config` — those must travel through the top-level `settings` field, not nested inside `config`.",
886978
);
@@ -1154,29 +1246,30 @@ export function createRemoteApp(
11541246
// malformed settings node on *other* servers in the file — a
11551247
// deliberate side-effect of using `readMcpConfig` + full rewrite
11561248
// here.
1157-
const existing = current.mcpServers[originalId]!;
1249+
const existing = current.mcpServers[originalId];
1250+
if (!existing) {
1251+
// The `in` check above guarantees this branch is unreachable;
1252+
// narrowing without the non-null assertion keeps TS happy and
1253+
// makes the contract explicit for future refactors.
1254+
return c.json(
1255+
{ error: `Server '${originalId}' not found` },
1256+
404,
1257+
);
1258+
}
11581259
// Split the existing entry into its SDK-only config (no Inspector-
11591260
// extension fields) and its lifted settings, then apply patch
11601261
// semantics from the body to each. The flat-on-disk Inspector
11611262
// fields are sliced off `existing` so the preserve-on-omit `config`
11621263
// path doesn't accidentally carry them through as raw disk keys —
11631264
// they need to flow through `buildStoredEntry` so empty `settings`
11641265
// intents can clear them.
1165-
const {
1166-
headers: existingHeaders,
1167-
metadata: existingMetadata,
1168-
connectionTimeout: existingCT,
1169-
requestTimeout: existingRT,
1170-
oauth: existingOauth,
1171-
...existingConfig
1172-
} = existing;
1173-
const existingSettings = storedFieldsToInspectorSettings({
1174-
headers: existingHeaders,
1175-
metadata: existingMetadata,
1176-
connectionTimeout: existingCT,
1177-
requestTimeout: existingRT,
1178-
oauth: existingOauth,
1179-
});
1266+
//
1267+
// `stripInspectorFields` + `storedFieldsToInspectorSettings` both
1268+
// derive from the same `INSPECTOR_FIELD_KEYS` set, so adding a new
1269+
// Inspector-extension field to `StoredMCPServer` doesn't silently
1270+
// leak through this preserve path.
1271+
const existingConfig = stripInspectorFields(existing);
1272+
const existingSettings = storedFieldsToInspectorSettings(existing);
11801273
const nextConfig =
11811274
body.config !== undefined ? body.config : existingConfig;
11821275
let nextSettings: InspectorServerSettings | undefined;

core/mcp/serverList.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ export function storedFieldsToInspectorSettings(
9595
connectionTimeout: stored.connectionTimeout ?? 0,
9696
requestTimeout: stored.requestTimeout ?? 0,
9797
};
98+
// Truthiness drops empty-string OAuth fields — mirrors the write-side
99+
// coercion in `validateSettings` (server.ts) so a round-trip can't
100+
// accidentally surface `oauthClientId: ""` to the form, where the
101+
// OAuth manager would misread it as "configured."
98102
if (stored.oauth?.clientId) settings.oauthClientId = stored.oauth.clientId;
99103
if (stored.oauth?.clientSecret)
100104
settings.oauthClientSecret = stored.oauth.clientSecret;
@@ -157,18 +161,51 @@ export function inspectorSettingsToStoredFields(
157161
}
158162

159163
/**
160-
* Set of known Inspector-extension keys that live at the top level of a
161-
* `StoredMCPServer`. Used by the disk → memory converter to slice the
162-
* extension fields off the entry so what remains is a pure SDK
163-
* `MCPServerConfig`.
164+
* Source of truth for the set of Inspector-extension keys that live at the
165+
* top level of a `StoredMCPServer`. Enumerated through a map keyed by
166+
* `keyof StoredInspectorFields` with a `satisfies` constraint so any new
167+
* field added to the type forces a compile error here — the disk → memory
168+
* converter slice, the server-side smuggle guard, and the PUT preserve
169+
* path all derive from this single source.
170+
*
171+
* Don't replace this with a hand-typed string array — `satisfies
172+
* Record<keyof StoredInspectorFields, true>` is what gives us the
173+
* exhaustive check. `as const` plus the `satisfies` clause yields a
174+
* narrow tuple-of-literals type that downstream consumers can use as
175+
* `(keyof StoredInspectorFields)[]`.
164176
*/
165-
const INSPECTOR_FIELD_KEYS = new Set<keyof StoredInspectorFields>([
166-
"headers",
167-
"metadata",
168-
"connectionTimeout",
169-
"requestTimeout",
170-
"oauth",
171-
]);
177+
const INSPECTOR_FIELD_KEY_MAP = {
178+
headers: true,
179+
metadata: true,
180+
connectionTimeout: true,
181+
requestTimeout: true,
182+
oauth: true,
183+
} as const satisfies Record<keyof StoredInspectorFields, true>;
184+
185+
export const INSPECTOR_FIELD_KEYS = new Set(
186+
Object.keys(INSPECTOR_FIELD_KEY_MAP) as (keyof StoredInspectorFields)[],
187+
);
188+
189+
/**
190+
* Strip the Inspector-extension fields off a `StoredMCPServer` so the
191+
* remainder is the pure SDK config shape the PUT route's preserve path
192+
* needs. Source-of-truth driven via `INSPECTOR_FIELD_KEYS` so adding a
193+
* new extension field doesn't silently leak through this slice — the
194+
* `satisfies` constraint above forces the map update, which propagates
195+
* here.
196+
*/
197+
export function stripInspectorFields(
198+
stored: StoredMCPServer,
199+
): MCPServerConfig {
200+
const out: Record<string, unknown> = {};
201+
for (const [k, v] of Object.entries(
202+
stored as unknown as Record<string, unknown>,
203+
)) {
204+
if (INSPECTOR_FIELD_KEYS.has(k as keyof StoredInspectorFields)) continue;
205+
out[k] = v;
206+
}
207+
return out as unknown as MCPServerConfig;
208+
}
172209

173210
/**
174211
* Convert the on-disk `MCPConfig` into the `ServerEntry[]` the Servers screen

specification/v2_servers_file.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,4 +261,4 @@ Each server entry may carry these Inspector-extension fields at the top level:
261261
- File watching for hot reload of external edits.
262262
- Per-server tags / folders / groups.
263263
- Export current list as JSON.
264-
- CLI/TUI: switch their default `--config` to `getDefaultMcpConfigPath()` when no `--config` flag is given. Touch when those clients are wired up to v2. While porting, re-add a `--header` flag that writes to `settings.headers` rather than to `MCPServerConfig`.
264+
- CLI/TUI: switch their default `--config` to `getDefaultMcpConfigPath()` when no `--config` flag is given. Touch when those clients are wired up to v2. While porting, re-add a `--header` flag that writes to the entry's top-level `headers` field on disk (post-#1358 flat shape) rather than to `MCPServerConfig`.

0 commit comments

Comments
 (0)