Hmbown/CodeWhale · error · anyhow

MCP consent history must be an object

Error message

MCP consent history must be an object

What it means

After JSON parsing succeeds, persist_decisions requires the top-level document to be a JSON object (the consent store lives as an object). A top-level array, string, number, or boolean is rejected with this error so malformed history is never overwritten.

Solutions

  1. Rewrite the file so its root is a JSON object (at minimum {}), preserving any object contents
  2. If the content is an array of servers, move it into the source-file shape (e.g. {"mcpServers":{...}}), not the consent/config path
  3. Validate the root type before writing: the parsed JSON must be a dict/object

Example fix

// before
[{"name":"fs","command":"npx"}]
// after
{"mcpServers":{"fs":{"command":"npx"}}}
Defensive patterns

Strategy: validation

Validate before calling

const doc = JSON.parse(fs.readFileSync(path, "utf8"));
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) { /* rewrite root as object */ }

Type guard

function isJsonObject(v) { return typeof v === "object" && v !== null && !Array.isArray(v); }

Try / catch

catch, then parse the file and confirm the root is an object; rewrite it as an object preserving valid keys before retrying

Prevention

When it happens

Trigger: `persist_decisions` is called when the config file parses as JSON but its root is not an object — e.g. the file contains an array or a bare string instead of an object.

Common situations: A tool or script rewrote the config as a JSON array; someone pasted a JSON array of servers into the consent/config file; an earlier export wrote the wrong root shape.

Understand the failure class

Background: "Invalid JSON response" and "Failed to parse response" errors: when an API answers 200 but the body isn't the JSON your library expected — this error's family across 28 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/c62e7ce8f3743ae2. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/mcp/external_import.rs:300

}

/// Record decisions against the latest consent document under the shared
/// process lock. Malformed history is never silently replaced with empty state.
pub fn persist_decisions(
    path: &Path,
    candidates: &[ImportCandidate],
    decisions: &HashMap<String, ImportDecision>,
    now_unix: u64,
) -> anyhow::Result<()> {
    super::validate_mcp_config_path(path)?;
    codewhale_config::with_config_write_lock(path, |path| {
        let original = super::read_mcp_config_file(path)?;
        let mut raw: Value = match original.as_deref() {
            Some(raw) => serde_json::from_str(raw)
                .map_err(|_| anyhow::anyhow!("Invalid MCP consent history; contents omitted"))?,
            None => serde_json::json!({}),
        };
        anyhow::ensure!(raw.is_object(), "MCP consent history must be an object");
        let mut store: ImportConsentStore = if original.is_none() {
            ImportConsentStore::default()
        } else {
            serde_json::from_value(raw.clone())
                .map_err(|_| anyhow::anyhow!("Invalid MCP consent history; contents omitted"))?
        };
        let before = serde_json::to_value(&store)?;
        record_decisions(&mut store, candidates, decisions, now_unix);
        let after = serde_json::to_value(&store)?;
        super::apply_json_delta(&mut raw, &before, &after);
        let rendered = serde_json::to_vec_pretty(&raw)?;
        if rendered.len() as u64 > super::MAX_MCP_CONFIG_BYTES {
            anyhow::bail!("MCP consent history exceeds size limit");
        }
        crate::utils::write_atomic(path, &rendered)?;
        Ok(())
    })
}

View on GitHub (pinned to 73e0f67d83)