* 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
1 changed files with 808 additions and 242 deletions
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MCPConfig,
|
||||
MCPServerConfig,
|
||||
ServerEntry,
|
||||
|
|
@ -50,18 +51,186 @@ export function normalizeServerType(
|
|||
return { ...config, type: normalizedType } as MCPServerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Inspector-extension fields that live as direct keys on a `StoredMCPServer`
|
||||
* (post-#1358) — split out from `MCPServerConfig` so both directions of the
|
||||
* converter can name them in one place. Equivalent to
|
||||
* `Pick<StoredMCPServer, "headers" | "metadata" | ...>` without re-listing.
|
||||
*/
|
||||
type StoredInspectorFields = Pick<
|
||||
StoredMCPServer,
|
||||
"headers" | "metadata" | "connectionTimeout" | "requestTimeout" | "oauth"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Lift the Inspector-extension fields off a freshly-read `StoredMCPServer`
|
||||
* into the pair-array / flat-OAuth `InspectorServerSettings` shape the form
|
||||
* and the rest of the in-memory layer consume. Returns `undefined` when none
|
||||
* of the source fields are present so callers can skip attaching a settings
|
||||
* node to entries that don't have one.
|
||||
*
|
||||
* `headers` becomes a pair-array preserving the object's key insertion order;
|
||||
* `oauth.*` becomes the flat `oauthClientId` / `oauthClientSecret` /
|
||||
* `oauthScopes` fields. Numeric timeouts default to 0 when absent — the form
|
||||
* needs concrete values to render and 0 is the SDK's "no timeout" signal.
|
||||
*/
|
||||
export function storedFieldsToInspectorSettings(
|
||||
stored: StoredInspectorFields,
|
||||
): InspectorServerSettings | undefined {
|
||||
const hasAny =
|
||||
stored.headers !== undefined ||
|
||||
stored.metadata !== undefined ||
|
||||
stored.connectionTimeout !== undefined ||
|
||||
stored.requestTimeout !== undefined ||
|
||||
stored.oauth !== undefined;
|
||||
if (!hasAny) return undefined;
|
||||
|
||||
const headersPairs: { key: string; value: string }[] = stored.headers
|
||||
? Object.entries(stored.headers).map(([key, value]) => ({ key, value }))
|
||||
: [];
|
||||
|
||||
const settings: InspectorServerSettings = {
|
||||
headers: headersPairs,
|
||||
metadata: stored.metadata ?? [],
|
||||
connectionTimeout: stored.connectionTimeout ?? 0,
|
||||
requestTimeout: stored.requestTimeout ?? 0,
|
||||
};
|
||||
// Truthiness drops empty-string OAuth fields — mirrors the write-side
|
||||
// coercion in `validateSettings` (server.ts) so a round-trip can't
|
||||
// accidentally surface `oauthClientId: ""` to the form, where the
|
||||
// OAuth manager would misread it as "configured."
|
||||
if (stored.oauth?.clientId) settings.oauthClientId = stored.oauth.clientId;
|
||||
if (stored.oauth?.clientSecret)
|
||||
settings.oauthClientSecret = stored.oauth.clientSecret;
|
||||
if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes;
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splat the form-shape `InspectorServerSettings` back into the on-disk
|
||||
* Inspector-extension fields (object-form `headers`, nested `oauth`, etc.).
|
||||
* Empty-key rows are dropped — the form lets users leave new rows blank
|
||||
* mid-edit and those shouldn't reach disk. Numeric timeouts at 0 are omitted
|
||||
* so the file diff stays minimal for entries that never touched them.
|
||||
*
|
||||
* Returns the field deltas to merge onto a `StoredMCPServer`; callers can
|
||||
* spread the result.
|
||||
*/
|
||||
export function inspectorSettingsToStoredFields(
|
||||
settings: InspectorServerSettings,
|
||||
): StoredInspectorFields {
|
||||
const out: StoredInspectorFields = {};
|
||||
|
||||
const headersRecord: Record<string, string> = {};
|
||||
for (const { key, value } of settings.headers) {
|
||||
if (key.trim() === "") continue;
|
||||
headersRecord[key] = value;
|
||||
}
|
||||
if (Object.keys(headersRecord).length > 0) {
|
||||
out.headers = headersRecord;
|
||||
}
|
||||
|
||||
const metadataFiltered = settings.metadata.filter(
|
||||
(m) => m.key.trim() !== "",
|
||||
);
|
||||
if (metadataFiltered.length > 0) {
|
||||
out.metadata = metadataFiltered;
|
||||
}
|
||||
|
||||
if (settings.connectionTimeout > 0) {
|
||||
out.connectionTimeout = settings.connectionTimeout;
|
||||
}
|
||||
if (settings.requestTimeout > 0) {
|
||||
out.requestTimeout = settings.requestTimeout;
|
||||
}
|
||||
|
||||
const oauthFields: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string;
|
||||
} = {};
|
||||
if (settings.oauthClientId) oauthFields.clientId = settings.oauthClientId;
|
||||
if (settings.oauthClientSecret)
|
||||
oauthFields.clientSecret = settings.oauthClientSecret;
|
||||
if (settings.oauthScopes) oauthFields.scopes = settings.oauthScopes;
|
||||
if (Object.keys(oauthFields).length > 0) {
|
||||
out.oauth = oauthFields;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of truth for the set of Inspector-extension keys that live at the
|
||||
* top level of a `StoredMCPServer`. Enumerated through a map keyed by
|
||||
* `keyof StoredInspectorFields` with a `satisfies` constraint so any new
|
||||
* field added to the type forces a compile error here — the disk → memory
|
||||
* converter slice, the server-side smuggle guard, and the PUT preserve
|
||||
* path all derive from this single source.
|
||||
*
|
||||
* Don't replace this with a hand-typed string array — `satisfies
|
||||
* Record<keyof StoredInspectorFields, true>` is what gives us the
|
||||
* exhaustive check. `as const` plus the `satisfies` clause yields a
|
||||
* narrow tuple-of-literals type that downstream consumers can use as
|
||||
* `(keyof StoredInspectorFields)[]`.
|
||||
*/
|
||||
const INSPECTOR_FIELD_KEY_MAP = {
|
||||
headers: true,
|
||||
metadata: true,
|
||||
connectionTimeout: true,
|
||||
requestTimeout: true,
|
||||
oauth: true,
|
||||
} as const satisfies Record<keyof StoredInspectorFields, true>;
|
||||
|
||||
export const INSPECTOR_FIELD_KEYS = new Set(
|
||||
Object.keys(INSPECTOR_FIELD_KEY_MAP) as (keyof StoredInspectorFields)[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Strip the Inspector-extension fields off a `StoredMCPServer` so the
|
||||
* remainder is the pure SDK config shape the PUT route's preserve path
|
||||
* needs. Source-of-truth driven via `INSPECTOR_FIELD_KEYS` so adding a
|
||||
* new extension field doesn't silently leak through this slice — the
|
||||
* `satisfies` constraint above forces the map update, which propagates
|
||||
* here.
|
||||
*/
|
||||
export function stripInspectorFields(
|
||||
stored: StoredMCPServer,
|
||||
): MCPServerConfig {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(
|
||||
stored as unknown as Record<string, unknown>,
|
||||
)) {
|
||||
if (INSPECTOR_FIELD_KEYS.has(k as keyof StoredInspectorFields)) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out as unknown as MCPServerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the on-disk `MCPConfig` into the `ServerEntry[]` the Servers screen
|
||||
* consumes. Map key becomes both `id` and `name`. Connection state initializes
|
||||
* to `disconnected` — the React layer drives it from there. The optional
|
||||
* `settings` node is lifted from the stored entry into `ServerEntry.settings`
|
||||
* so the rest of the app sees `config` as the pure SDK shape.
|
||||
* to `disconnected` — the React layer drives it from there. Inspector-extension
|
||||
* fields (post-#1358 flat shape) are lifted into `ServerEntry.settings` so the
|
||||
* rest of the app sees `config` as the pure SDK shape.
|
||||
*/
|
||||
export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[] {
|
||||
return Object.entries(config.mcpServers).map(([id, raw]) => {
|
||||
const { settings, ...rest } = raw as StoredMCPServer;
|
||||
// Separate Inspector-extension fields from the SDK-only config so the
|
||||
// transport never sees `entry.config.headers` (which would be ambiguous
|
||||
// — pair-array in memory, object on disk). Headers live on the wire via
|
||||
// `InspectorServerSettings` only.
|
||||
const inspectorFields: StoredInspectorFields = {};
|
||||
const sdkOnly: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(raw as unknown as Record<string, unknown>)) {
|
||||
if (INSPECTOR_FIELD_KEYS.has(k as keyof StoredInspectorFields)) {
|
||||
(inspectorFields as Record<string, unknown>)[k] = v;
|
||||
} else {
|
||||
sdkOnly[k] = v;
|
||||
}
|
||||
}
|
||||
const normalizedConfig = normalizeServerType(
|
||||
rest as unknown as Record<string, unknown> & { type?: string },
|
||||
sdkOnly as Record<string, unknown> & { type?: string },
|
||||
);
|
||||
const entry: ServerEntry = {
|
||||
id,
|
||||
|
|
@ -69,6 +238,7 @@ export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[] {
|
|||
config: normalizedConfig,
|
||||
connection: { status: "disconnected" },
|
||||
};
|
||||
const settings = storedFieldsToInspectorSettings(inspectorFields);
|
||||
if (settings !== undefined) entry.settings = settings;
|
||||
return entry;
|
||||
});
|
||||
|
|
@ -76,15 +246,18 @@ export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[] {
|
|||
|
||||
/**
|
||||
* Convert `ServerEntry[]` back into `MCPConfig` for serialization. Strips
|
||||
* runtime-only fields (connection, info, name); persists `config` and the
|
||||
* optional `settings` node so the file remains a clean canonical `mcp.json`
|
||||
* plus the Inspector-specific extension.
|
||||
* runtime-only fields (connection, info, name); persists `config` plus the
|
||||
* Inspector-extension fields as direct keys on the entry (post-#1358 flat
|
||||
* shape) so the file matches the Claude Code / Cursor / Cline `.mcp.json`
|
||||
* convention.
|
||||
*/
|
||||
export function serverEntriesToMcpConfig(entries: ServerEntry[]): MCPConfig {
|
||||
const mcpServers: Record<string, StoredMCPServer> = {};
|
||||
for (const entry of entries) {
|
||||
const stored: StoredMCPServer = { ...entry.config } as StoredMCPServer;
|
||||
if (entry.settings !== undefined) stored.settings = entry.settings;
|
||||
if (entry.settings !== undefined) {
|
||||
Object.assign(stored, inspectorSettingsToStoredFields(entry.settings));
|
||||
}
|
||||
mcpServers[entry.id] = stored;
|
||||
}
|
||||
return { mcpServers };
|
||||
|
|
|
|||
Loading…
Reference in a new issue