JuliusBrussee/caveman · error

mcpServers must be a JSON object; refusing to overwrite it

Error message

${mcpPath} mcpServers must be a JSON object; refusing to overwrite it

What it means

`claudeNativeMutations()` writes the Caveman MCP server entry into Claude's global config (`claudeGlobalConfigPath()`). It validates that the existing `mcpServers` key is a plain JSON object; if it is any other type (string, array, null), the CLI aborts rather than overwrite data it cannot safely merge. The file must be repaired by hand.

Solutions

  1. Open the config file in the message and convert `mcpServers` to a JSON object keyed by server name
  2. Back up the file before editing (cp file file.bak)
  3. If mcpServers is an array, transform each entry into an object keyed by its name field
  4. Re-run `caveman setup` once the shape is corrected

Example fix

// before: ~/.claude.json
{ "mcpServers": [ { "caveman": { "command": "caveman-mcp" } } ] }
// after
{ "mcpServers": { "caveman": { "type": "stdio", "command": "caveman-mcp", "args": [], "env": {} } } }
Defensive patterns

Strategy: type-guard

Validate before calling

// Pre-flight check on Claude global config:
const cfg = JSON.parse(fs.readFileSync(claudeConfigPath, 'utf8'));
if (cfg.mcpServers !== undefined && (typeof cfg.mcpServers !== 'object' || cfg.mcpServers === null || Array.isArray(cfg.mcpServers))) {
  console.error(`Fix ${claudeConfigPath}: mcpServers must be an object`);
}

Type guard

function isMcpServerMap(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

Try / catch

try {
  await cavemanSetup(['claude']);
} catch (e) {
  if (String(e.message).includes('mcpServers must be a JSON object')) {
    // repair mcpServers shape in the reported file, then retry
  }
  throw e;
}

Prevention

When it happens

Trigger: Running native Claude setup when the parsed global config has `mcpServers` defined but with a non-object value (`typeof mcpRoot.mcpServers !== "object" || null || Array.isArray`).

Common situations: Manually edited ~/.claude.json where mcpServers became an array of servers instead of a keyed object; a sync tool that flattened mcpServers; corrupted config after an editor mishap; null placeholder from a generated template.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/a2dc47a2c8ca588f. Report an issue: GitHub.

Appendix: source

Thrown at packages/cli/src/index.ts:7058

  // user already carries in settings.
  for (const [key, value] of Object.entries(gatewayNoProxyEnv(gw))) {
    if (env[key] === undefined) env[key] = value;
  }
  // Claude Code turns tool search (progressive MCP tool disclosure) OFF as soon
  // as ANTHROPIC_BASE_URL is not a first-party Anthropic host, so pointing it at
  // caveman would otherwise force every MCP tool schema inline on every request
  // — measured at ~48k extra prompt tokens on a 4-server setup. The proxy
  // forwards tool_reference blocks byte-identically, which is the condition
  // Claude Code names for the override. Never clobber an explicit user value.
  if (env.ENABLE_TOOL_SEARCH === undefined) env.ENABLE_TOOL_SEARCH = TOOL_SEARCH_DEFAULT;
  settings.env = env;
  const withHooks = nativeHooksDocument("claude", nativeShrinkEnabled(), settings);

  const mcpPath = claudeGlobalConfigPath();
  const mcpBefore = fileBytes(mcpPath);
  const mcpRoot = parseJsonFileObject(mcpPath, mcpBefore);
  if (mcpRoot.mcpServers !== undefined && (typeof mcpRoot.mcpServers !== "object" || mcpRoot.mcpServers === null || Array.isArray(mcpRoot.mcpServers))) {
    throw new Error(`${mcpPath} mcpServers must be a JSON object; refusing to overwrite it`);
  }
  const servers = mcpRoot.mcpServers && typeof mcpRoot.mcpServers === "object" && !Array.isArray(mcpRoot.mcpServers)
    ? mcpRoot.mcpServers as Record<string, unknown>
    : {};
  const previousMcp = servers.caveman;
  const installedMcp = { type: "stdio", command: mcpBinary, args: [], env: {} };
  servers.caveman = installedMcp;
  mcpRoot.mcpServers = servers;

  return [
    {
      file: settingsPath,
      before: settingsBefore,
      after: Buffer.from(JSON.stringify(withHooks, null, 2) + "\n"),
      kind: "claude-settings",
      owned: { route, previous_route: previousRoute ?? null, assume_first_party: assumeFirstParty ? "1" : null },
    },
    {

View on GitHub (pinned to 3ee70a1026)