Skip to content

fix(cli): distinguish an unreadable MCP enablement config from a missing one - #29445

Closed
lets-order-some-fries wants to merge 2 commits into
google-gemini:mainfrom
lets-order-some-fries:fix/mcp-enablement-corrupt-config
Closed

lets-order-some-fries wants to merge 2 commits into
google-gemini:mainfrom
lets-order-some-fries:fix/mcp-enablement-corrupt-config

Conversation

@lets-order-some-fries

@lets-order-some-fries lets-order-some-fries commented Sep 22, 2026 •

Copy link
Copy Markdown

Summary

A corrupt mcp-server-enablement.json currently fails open: every MCP server the
user deliberately disabled is reported as enabled, connected, and its tools are
exposed to the model. The next disable() then writes a fresh one-entry file
over the corrupt one, erasing every other entry.

Both follow from readConfig() collapsing "the file does not exist" and "the
file exists but cannot be read" into the same {}.

Details

readConfig() returned {} for a SyntaxError exactly as it did for ENOENT.
Downstream, isFileEnabled() reads state?.enabled ?? true, so an empty config
means everything is enabled — a trust boundary that fails in the permissive
direction. disable() then round-trips that same {} back to disk with one key
added, and the unreadable contents are gone.

This PR keeps the two cases apart:

  • readConfig() returns a ReadConfigResult discriminated on
    ok / missing / unreadable. Only missing yields an empty config.
  • isFileEnabled() fails closed on unreadable. A file that cannot be read
    may disable this server, and we cannot tell which.
  • enable() and disable() refuse to write on unreadable, throwing
    McpServerEnablementConfigError, so the file on disk is preserved for the
    user to repair.
  • Valid JSON of the wrong shape is treated as unreadable too. {"playwright": "off"} parses fine, leaves state.enabled undefined, and fails open by the
    same ?? true. Entries carrying unknown extra fields are still accepted so
    older clients tolerate configs written by newer ones.
  • The error is reported once per stretch of failures rather than on every read —
    isFileEnabled() runs for every server on every connection attempt — and now
    names the file path and the remedy.

Call sites that write: gemini mcp enable/disable and /mcp enable|disable now
print the refusal instead of rejecting. autoEnableServers() (extension enable)
stops and returns what it managed, since nothing can be re-enabled while the
file is unreadable.

Deliberately out of scope: canLoadServer() still reports blockType: 'enablement' with "Run 'gemini mcp enable '" when a server is blocked
this way. Distinguishing "disabled" from "config unreadable" there means
widening the EnablementCallbacks boolean contract that packages/core
consumes. The emitted error explains the real cause in the meantime.

writeConfig is left non-atomic, exactly as on main — it is byte-identical
between the two, and this PR only changes its two call sites. A truncate-then-
write can still be observed mid-tear by a second gemini process, which now
reads as corruption. Note the direction though: on main that same tear made
disable() read {}, add one key and permanently erase every other entry;
here it refuses to write. A tmp-file + rename write (the pattern already used
in trust.ts, projectRegistry.ts and integrity.ts) is a separate change and
belongs in its own PR.

Related Issues

Fixes #28786

How to Validate

Unit tests (14 existing + 15 added, all green). 10 of the added cases fail on
main and are direct regression tests for the two defects:

npx vitest run src/config/mcp/mcpServerEnablement.test.ts --root packages/cli

End to end, against a scratch home so your real config is untouched:

Setup, then the part that reproduces on this branch alone:

npm run bundle
CLI="$PWD/bundle/gemini.js"   # `gemini` is not on PATH from a source checkout --
                              # CONTRIBUTING suggests an alias or `npm link` instead.
                              # Do NOT use a globally installed `gemini` here: it would
                              # run the released build, without this change.

export HOME=$(mktemp -d) && mkdir -p "$HOME/.gemini" "$HOME/proj"
printf '{"mcpServers":{"playwright":{"command":"echo"},"github":{"command":"echo"},"other":{"command":"echo"}}}' \
  > "$HOME/.gemini/settings.json"

# a config that disables one server, then loses its tail to a truncated write
printf '{"playwright": {"enabled": false}, "github": ' \
  > "$HOME/.gemini/mcp-server-enablement.json"
cd "$HOME/proj"
export GEMINI_CLI_TRUST_WORKSPACE=true   # the scratch dir is not a trusted folder

node "$CLI" mcp list

Observed on this branch (macOS):

Configured MCP servers:

Failed to read MCP server enablement config at <path>. Every MCP server is
treated as disabled until the file is repaired or deleted.
○ playwright: echo  (stdio) - Disabled
○ github: echo  (stdio) - Disabled
○ other: echo  (stdio) - Disabled

On main, the same command reports playwright as enabled and connects it.

The write-refusal half needs #29444 applied on top. On current main,
gemini mcp enable|disable exits early with "Server not found" for every server —
a separate bug, fixed in #29444 — so the write path here is unreachable from the CLI
until that lands. With both applied:

node "$CLI" mcp disable other
node "$CLI" mcp enable playwright
md5 "$HOME/.gemini/mcp-server-enablement.json"   # unchanged by both attempts
Error: Cannot update MCP server enablement: <path> exists but could not be read.
Repair or delete that file and retry. Until then every MCP server is treated as disabled.

with the file byte-identical (same md5) after both refused writes. Every path here is
covered by unit tests either way. The two branches touch adjacent lines of
enableDisable.ts; whichever lands second needs a one-line import rebase.

Pre-Merge Checklist

  • Updated relevant documentation and README (if needed) — docs/tools/mcp-server.md, the one place mcp-server-enablement.json is documented
  • Added/updated tests (if needed)
  • Noted breaking changes (if any) — behaviour change, not API: an unreadable
    config now disables MCP servers rather than silently enabling them, and
    enable/disable refuse to write until it is repaired. That is the point of
    the fix; a missing config behaves exactly as before.
  • Validated on required platforms/methods:
    • MacOS
      • npm run
      • npx
      • Docker
      • Podman
      • Seatbelt
    • Windows
      • npm run
      • npx
      • Docker
    • Linux
      • npm run
      • npx
      • Docker

The change is filesystem-and-JSON only, with no platform-specific paths beyond
Storage.getGlobalGeminiDir(), which it does not touch.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

📊 PR Size: size/L

  • Lines changed: 337
  • Additions: +312
  • Deletions: -25
  • Files changed: 6

@google-cla

google-cla Bot commented Sep 22, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@lets-order-some-fries
lets-order-some-fries force-pushed the fix/mcp-enablement-corrupt-config branch from 55863d0 to d1822ff Compare September 22, 2026 15:22
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a critical issue where a corrupt MCP server enablement configuration file would cause the system to fail open, effectively enabling all servers. By introducing explicit handling for unreadable configuration states, the system now defaults to a safer, closed state and prevents destructive write operations that would otherwise overwrite existing data when the configuration is malformed.

Highlights

  • Config Readability Discrimination: Updated readConfig to distinguish between a missing configuration file and an unreadable/corrupt one, preventing the system from defaulting to an 'all-enabled' state when a file is corrupted.
  • Fail-Closed Behavior: Implemented a fail-closed policy for unreadable configurations, ensuring that MCP servers are treated as disabled until the configuration file is repaired or deleted.
  • Write Protection: Added safeguards to enable and disable methods to refuse writing to disk if the existing configuration file is unreadable, preserving user data for potential repair.
  • Error Reporting: Introduced a rate-limited error reporting mechanism to notify users of configuration issues without flooding logs during repeated connection attempts.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request enhances the robustness of the MCP server enablement configuration by introducing strict validation and graceful error handling for malformed or unreadable configuration files. It defines a new McpServerEnablementConfigError to prevent overwriting corrupted configurations, implements a fail-closed mechanism when the configuration cannot be parsed, and adds try-catch blocks in the CLI commands to cleanly report errors to the user. Additionally, comprehensive unit tests have been added to verify these error-handling behaviors. I have no feedback to provide as the changes are well-implemented and thoroughly tested.

@lets-order-some-fries

Copy link
Copy Markdown
Author

@googlebot I signed it!

@gemini-cli gemini-cli Bot added priority/p1 Important and should be addressed in the near term. area/core Issues related to User Interface, OS Support, Core Functionality labels Sep 22, 2026
…ing one

readConfig() returned {} for a SyntaxError exactly as it did for ENOENT.
Downstream isFileEnabled() reads `state?.enabled ?? true`, so an empty
config means everything is enabled - a trust boundary failing in the
permissive direction. Every server the user disabled gets connected and
its tools exposed. disable() then round-trips that same {} back to disk
with one key added, erasing the rest of the file.

readConfig() now returns a result discriminated on ok/missing/unreadable.
Only missing yields an empty config. isFileEnabled() fails closed on
unreadable; enable() and disable() refuse to write and throw
McpServerEnablementConfigError, preserving the file for the user to repair.

Valid JSON of the wrong shape fails open by the same route - {"x": "off"}
parses fine and leaves state.enabled undefined - so it is treated as
unreadable too, while entries with unknown extra fields are still accepted.

The error is reported once per stretch of failures rather than on every
read, and names the file path and the remedy.

Fixes google-gemini#28786
…t-once arms

The new suite keyed its in-memory fs on a POSIX literal while the manager
builds the same path with path.join, so on the test_windows shard the key
never matched: readFile threw the mock's ENOENT, readConfig returned
`missing` instead of `unreadable`, and most of the new cases failed. That
shard is a hard dependency of the final `ci` gate, so the PR could not have
merged. Derive the constant the same way the manager does.

Also covers two arms the suite left unobserved: a read that fails with
something other than ENOENT (every other case supplies readable bytes and
fails later at JSON.parse or the shape check), and the report-once-per-
stretch behaviour, which no test observed at all - deleting the guard or
either reset left the suite green.

Documents the behaviour in docs/tools/mcp-server.md, which is where
mcp-server-enablement.json is described. Worded around the slash commands:
the `gemini mcp enable|disable` path is separately broken until google-gemini#29444.
@lets-order-some-fries

Copy link
Copy Markdown
Author

For whoever reviews this: the sibling file has the identical defect, and I have opened #29481 for it.

ExtensionEnablementManager.readConfig() collapses a parse or schema failure into the same {} it uses for ENOENT, and isEnabled() starts from let enabled = true, so an unreadable extension-enablement.json silently re-enables every extension the user disabled — then writeConfig(), which serialises the whole map unconditionally, drops the rest of the file on the next enable/disable/remove.

Reproduced on main: with two extensions disabled, truncating three bytes flips both back to enabled, and the next extensions disable leaves a file containing only the new entry. Arguably the worse of the two, since extensions carry MCP servers, tools, commands and hooks.

#29481 uses the same shape as this PR deliberately, so the two read together. Happy to fold it into this one if you would rather review a single change.

@gemini-cli

gemini-cli Bot commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Hi there! Thank you for your interest in contributing to Gemini CLI.

To ensure we maintain high code quality and focus on our prioritized roadmap, we only guarantee review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'.

This PR will be closed in 7 days if it remains without that designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.

@gemini-cli

gemini-cli Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

This pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.

@gemini-cli gemini-cli Bot closed this Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core Issues related to User Interface, OS Support, Core Functionality priority/p1 Important and should be addressed in the near term. size/l A large sized PR status/pr-nudge-sent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Corrupt MCP enablement config silently re-enables servers, then disable() erases it

2 participants