Skip to content

Eliminate the mcp.json settings node and promote its fields to the top level #1358

@cliffhall

Description

@cliffhall

Background

#1352 (PR #1353, merged) shipped per-server settings persistence by nesting them under a settings block 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 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:

{
  "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) 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.

{
  "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.

  1. 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).

  2. 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.

  3. 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.

  4. 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.

  5. 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.tsmcpConfigToServerEntries / 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.tsvalidateSettings 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.tsmergeMeta / 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.tsupdateServerSettings(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.
  • 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.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.
  • Loading a file that contains a legacy settings node (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 no settings field.
  • 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.

Metadata

Metadata

Assignees

Labels

v2Issues and PRs for v2

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions