You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The rationale at the time was that other MCP tools (Claude Desktop, Cursor, Cline) would treat settings as an unknown key and ignore it — but the inverse is now the more important consideration: we want to accept headers written at the top level by those tools, because that is where the broader ecosystem actually puts them.
Evidence
The Claude Code docs at https://code.claude.com/docs/en/mcp document headers at the top level of each mcpServers entry, alongside type / url:
(See "Environment variable expansion in .mcp.json" → "Expansion locations" / "Example with variable expansion".)
oauth (an object) and timeout (a number) are likewise documented at the top level. env on stdio entries already matches that convention in our current shape (and has since v1.5).
Proposal
Eliminate the settings wrapper on disk. Each field that currently lives under settings.* is promoted to a sibling of type / url / command on the server entry. Inspector-specific fields keep their names; their keys remain unknown to other tools and are ignored on those tools' lenient reads.
All five sub-decisions are settled — the implementation PR should follow them, not relitigate them.
headers shape: Record<string, string>. Aligns with Claude / Cursor / Cline (which all document the object form). HTTP semantically allows only one value per header name, so we lose nothing real. The UI form can keep its internal Array<{ key, value }> pair model; conversion happens at the persistence boundary inside ServerSettingsForm (or its container).
OAuth grouping: nested oauth: { clientId, clientSecret, scopes }. Matches Claude's shape and leaves room for additive fields (callbackPort, authServerMetadataUrl) without another schema break. A nested object is still "top-level" in the sense this issue intends — what we're eliminating is the catch-all settings wrapper, not all nesting.
Inspector-specific fields: flatten as-is to the top level.metadata, connectionTimeout, and requestTimeout become direct siblings of type / url. They have no analog in the broader mcp.json ecosystem and are not interop targets — other tools ignore unknown keys.
Migration: hard cutover.normalizeMcpServers ignores any settings node on read and logs a one-line warn including the server id. Files written by Persist per-server settings (headers, metadata, timeouts, OAuth credentials) to mcp.json #1352 lose their persisted headers / metadata / timeouts / OAuth credentials 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-settings shape, so the blast radius is the small set of v2/main dogfooders who edited per-server settings between the feat(servers): persist per-server settings to mcp.json (#1352) #1353 merge and this change.
Wire shape on PUT /api/servers/:id: keep settings as a wire-only patch envelope. The on-disk shape flattens (per the proposal above) but the request body still carries { id, config, settings } with the same preserve/clear/apply semantics feat(servers): persist per-server settings to mcp.json (#1352) #1353 introduced. Backend splats settings.* onto the entry inside the write lock when assembling the next-on-disk shape; the response shape returned by GET /api/servers reflects the flat on-disk shape. The patch-PUT semantics were designed around two named sub-trees and they're load-bearing — collapsing the wire to a single field-level patch is a bigger surface change and risks reintroducing the "config-save silently wipes settings" footgun the existing route was tuned to prevent. The user-visible shape is what lands on disk; the wire envelope is an implementation detail.
Files to touch
core/mcp/types.ts — flatten InspectorServerSettings fields onto StoredMCPServer (and the corresponding MCPServerConfig variants where they're consumed). InspectorServerSettings itself stays as a named typedef for the wire patch payload and the form's internal state (per decision (5)).
core/mcp/serverList.ts — mcpConfigToServerEntries / serverEntriesToMcpConfig round-trip the flattened shape. On read, drop any settings node and log a warn so dogfooders see what happened (per decision (4)).
core/mcp/remote/node/server.ts — validateSettings stays as the validator for the wire settings envelope (per decision (5)); the patch-PUT preserve/clear/apply semantics are unchanged on the wire. The route's write-side splat: validated settings.* fields are assigned as direct keys on StoredMCPServer when building the next on-disk shape. buildStoredEntry's "strip smuggled settings from config" guard becomes "strip smuggled known top-level fields when the caller passes them in two places."
core/mcp/node/transport.ts and core/mcp/remote/remoteClientTransport.ts — read headers / connectionTimeout / requestTimeout / metadata directly from the entry rather than from settings. The headers convert from Record<string, string> to the wire Headers directly — no more pair-array unpacking.
core/mcp/inspectorClient.ts — mergeMeta / serverSettings plumbing reflects the new shape. InspectorClientOptions.serverSettings: InspectorServerSettings | undefined continues to take the composite typedef (per decision (5)); its definition is the flattened shape.
core/react/useServers.ts — updateServerSettings(id, settings) keeps its existing signature and PUT body shape ({ id, settings }); the backend splat is the only thing changing under it.
clients/web/src/components/groups/ServerSettingsForm/* — form internal state is unchanged; the serializer at the boundary changes. Per decision (1), the headers form ↔ wire conversion is the only meaningful UI-side change: pairs in the form become a Record<string, string> going out, and an object coming back populates the pair list with stable order.
mcp.json files written by Inspector have no settings node; all former-settings fields live as direct keys on each server entry, matching the example in the Proposal above.
Loading an mcp.json file that has top-level headers (e.g. one written by Claude Code or pasted from the Claude docs) works without any per-server-form interaction — the wire Authorization: Bearer … lands on the very first connect.
PUT /api/servers/:id request body shape is unchanged from feat(servers): persist per-server settings to mcp.json (#1352) #1353 — { id?, config?, settings? } with the preserve/clear/apply semantics intact; the backend splats validated settings.* onto top-level keys when assembling the next on-disk shape.
The ServerConfigModal vs ServerSettingsForm UI split is unchanged. The persistence boundary is the only thing moving.
Round-trip and first-connect integration tests cover: (a) external file with top-level headers only — wire headers land on first connect; (b) round-trip of all promoted fields through the form + persistence + reload; (c) a fixture file containing a legacy settings node loads with the fields dropped and a warn emitted; (d) a settings-only PUT body still preserves the on-disk transport config (the existing feat(servers): persist per-server settings to mcp.json (#1352) #1353 contract holds across the wire-shape envelope).
specification/v2_servers_file.md reflects the flat on-disk shape end to end (example, prose, and the spec callouts that currently reference settings.*), with the kept wire envelope and the legacy-drop behavior documented.
Out of scope
Aligning the OAuth flow with Claude's additional fields (callbackPort, authServerMetadataUrl) — those are pure additions and can land later without a schema break once the nested oauth object exists.
Environment-variable expansion in mcp.json (${API_KEY} etc.) — separate ecosystem-feature ask, not a schema-shape issue.
Renaming requestTimeout / connectionTimeout to match Claude's single timeout semantic. Inspector splits the two deliberately and they're independently useful.
Background
#1352 (PR #1353, merged) shipped per-server settings persistence by nesting them under a
settingsblock on each entry in~/.mcp-inspector/mcp.json:{ "mcpServers": { "acme-api": { "type": "streamable-http", "url": "https://api.acme.example/mcp", "settings": { "headers": [{ "key": "Authorization", "value": "Bearer xxx" }], "metadata": [{ "key": "tenant", "value": "acme" }], "connectionTimeout": 30000, "requestTimeout": 60000, "oauthClientId": "...", "oauthClientSecret": "...", "oauthScopes": "read:tools write:tools" } } } }The rationale at the time was that other MCP tools (Claude Desktop, Cursor, Cline) would treat
settingsas an unknown key and ignore it — but the inverse is now the more important consideration: we want to acceptheaderswritten at the top level by those tools, because that is where the broader ecosystem actually puts them.Evidence
The Claude Code docs at https://code.claude.com/docs/en/mcp document
headersat the top level of eachmcpServersentry, alongsidetype/url:{ "mcpServers": { "api-server": { "type": "http", "url": "${API_BASE_URL:-https://api.example.com}/mcp", "headers": { "Authorization": "Bearer ${API_KEY}" } } } }(See "Environment variable expansion in
.mcp.json" → "Expansion locations" / "Example with variable expansion".)oauth(an object) andtimeout(a number) are likewise documented at the top level.envon stdio entries already matches that convention in our current shape (and has since v1.5).Proposal
Eliminate the
settingswrapper on disk. Each field that currently lives undersettings.*is promoted to a sibling oftype/url/commandon the server entry. Inspector-specific fields keep their names; their keys remain unknown to other tools and are ignored on those tools' lenient reads.{ "mcpServers": { "acme-api": { "type": "streamable-http", "url": "https://api.acme.example/mcp", "headers": { "Authorization": "Bearer xxx" }, "metadata": [{ "key": "tenant", "value": "acme" }], "connectionTimeout": 30000, "requestTimeout": 60000, "oauth": { "clientId": "...", "clientSecret": "...", "scopes": "read:tools write:tools" } } } }Decisions
All five sub-decisions are settled — the implementation PR should follow them, not relitigate them.
headersshape:Record<string, string>. Aligns with Claude / Cursor / Cline (which all document the object form). HTTP semantically allows only one value per header name, so we lose nothing real. The UI form can keep its internalArray<{ key, value }>pair model; conversion happens at the persistence boundary insideServerSettingsForm(or its container).OAuth grouping: nested
oauth: { clientId, clientSecret, scopes }. Matches Claude's shape and leaves room for additive fields (callbackPort,authServerMetadataUrl) without another schema break. A nested object is still "top-level" in the sense this issue intends — what we're eliminating is the catch-allsettingswrapper, not all nesting.Inspector-specific fields: flatten as-is to the top level.
metadata,connectionTimeout, andrequestTimeoutbecome direct siblings oftype/url. They have no analog in the broader mcp.json ecosystem and are not interop targets — other tools ignore unknown keys.Migration: hard cutover.
normalizeMcpServersignores anysettingsnode on read and logs a one-line warn including the server id. Files written by Persist per-server settings (headers, metadata, timeouts, OAuth credentials) to mcp.json #1352 lose their persisted headers / metadata / timeouts / OAuth credentials 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-settingsshape, so the blast radius is the small set of v2/main dogfooders who edited per-server settings between the feat(servers): persist per-server settings to mcp.json (#1352) #1353 merge and this change.Wire shape on
PUT /api/servers/:id: keepsettingsas a wire-only patch envelope. The on-disk shape flattens (per the proposal above) but the request body still carries{ id, config, settings }with the same preserve/clear/apply semantics feat(servers): persist per-server settings to mcp.json (#1352) #1353 introduced. Backend splatssettings.*onto the entry inside the write lock when assembling the next-on-disk shape; the response shape returned byGET /api/serversreflects the flat on-disk shape. The patch-PUT semantics were designed around two named sub-trees and they're load-bearing — collapsing the wire to a single field-level patch is a bigger surface change and risks reintroducing the "config-save silently wipes settings" footgun the existing route was tuned to prevent. The user-visible shape is what lands on disk; the wire envelope is an implementation detail.Files to touch
core/mcp/types.ts— flattenInspectorServerSettingsfields ontoStoredMCPServer(and the correspondingMCPServerConfigvariants where they're consumed).InspectorServerSettingsitself stays as a named typedef for the wire patch payload and the form's internal state (per decision (5)).core/mcp/serverList.ts—mcpConfigToServerEntries/serverEntriesToMcpConfiground-trip the flattened shape. On read, drop anysettingsnode and log a warn so dogfooders see what happened (per decision (4)).core/mcp/remote/node/server.ts—validateSettingsstays as the validator for the wiresettingsenvelope (per decision (5)); the patch-PUT preserve/clear/apply semantics are unchanged on the wire. The route's write-side splat: validatedsettings.*fields are assigned as direct keys onStoredMCPServerwhen building the next on-disk shape.buildStoredEntry's "strip smuggledsettingsfromconfig" guard becomes "strip smuggled known top-level fields when the caller passes them in two places."core/mcp/node/transport.tsandcore/mcp/remote/remoteClientTransport.ts— readheaders/connectionTimeout/requestTimeout/metadatadirectly from the entry rather than fromsettings. The headers convert fromRecord<string, string>to the wireHeadersdirectly — no more pair-array unpacking.core/mcp/inspectorClient.ts—mergeMeta/serverSettingsplumbing reflects the new shape.InspectorClientOptions.serverSettings: InspectorServerSettings | undefinedcontinues to take the composite typedef (per decision (5)); its definition is the flattened shape.core/react/useServers.ts—updateServerSettings(id, settings)keeps its existing signature and PUT body shape ({ id, settings }); the backend splat is the only thing changing under it.clients/web/src/components/groups/ServerSettingsForm/*— form internal state is unchanged; the serializer at the boundary changes. Per decision (1), theheadersform ↔ wire conversion is the only meaningful UI-side change: pairs in the form become aRecord<string, string>going out, and an object coming back populates the pair list with stable order.clients/web/src/components/groups/ServerSettingsModal/*— passthrough.specification/v2_servers_file.md— rewrite the on-disk example and the "Per-server settings (Persist per-server settings (headers, metadata, timeouts, OAuth credentials) to mcp.json #1352)" section to reflect the flat on-disk shape, the kept wire envelope, and the hard-cutover legacy behavior. Remove the "Inspector-specific extension" framing.Acceptance criteria
mcp.jsonfiles written by Inspector have nosettingsnode; all former-settings fields live as direct keys on each server entry, matching the example in the Proposal above.mcp.jsonfile that has top-levelheaders(e.g. one written by Claude Code or pasted from the Claude docs) works without any per-server-form interaction — the wireAuthorization: Bearer …lands on the very first connect.settingsnode (written by Persist per-server settings (headers, metadata, timeouts, OAuth credentials) to mcp.json #1352): the node is dropped on read, a one-line warn is logged including the server id, and the next save persists the flat shape with nosettingsfield.PUT /api/servers/:idrequest body shape is unchanged from feat(servers): persist per-server settings to mcp.json (#1352) #1353 —{ id?, config?, settings? }with the preserve/clear/apply semantics intact; the backend splats validatedsettings.*onto top-level keys when assembling the next on-disk shape.ServerConfigModalvsServerSettingsFormUI split is unchanged. The persistence boundary is the only thing moving.headersonly — wire headers land on first connect; (b) round-trip of all promoted fields through the form + persistence + reload; (c) a fixture file containing a legacysettingsnode loads with the fields dropped and a warn emitted; (d) a settings-onlyPUTbody still preserves the on-disk transport config (the existing feat(servers): persist per-server settings to mcp.json (#1352) #1353 contract holds across the wire-shape envelope).specification/v2_servers_file.mdreflects the flat on-disk shape end to end (example, prose, and the spec callouts that currently referencesettings.*), with the kept wire envelope and the legacy-drop behavior documented.Out of scope
callbackPort,authServerMetadataUrl) — those are pure additions and can land later without a schema break once the nestedoauthobject exists.mcp.json(${API_KEY}etc.) — separate ecosystem-feature ask, not a schema-shape issue.requestTimeout/connectionTimeoutto match Claude's singletimeoutsemantic. Inspector splits the two deliberately and they're independently useful.