Skip to content

fix(config): enforce MCP trust boundary so project config cannot override user disable#609

Open
Ashwinhegde19 wants to merge 2 commits into
Gitlawb:mainfrom
Ashwinhegde19:fix/mcp-trust-boundary-user-disable-sticky
Open

fix(config): enforce MCP trust boundary so project config cannot override user disable#609
Ashwinhegde19 wants to merge 2 commits into
Gitlawb:mainfrom
Ashwinhegde19:fix/mcp-trust-boundary-user-disable-sticky

Conversation

@Ashwinhegde19

@Ashwinhegde19 Ashwinhegde19 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #512 — a user-level MCP server disable is now sticky. The project config (.zero/config.json in a cloned repo) can no longer override the user's explicit choice:

  • Cannot re-enable a server the user disabled.
  • Cannot disable a server the user explicitly enabled.

The old merge guard was asymmetric: it only blocked re-enabling a user-disabled server, so a repo that set disabled: true could silently defeat a user's mcp enable <server>. The guard is now symmetric — once a higher-trust scope has explicitly set the disabled flag, a lower-trust layer may not override that decision in either direction. Only the CLI override scope (canReenable = true) may lift a sticky user disable.

Changes

  • internal/config/resolver.go: mergeMCPConfig / mergeMCPServer take a canReenable bool. ResolveMCP passes canReenable = path != options.ProjectConfigPath (user → true, project → false). The disabled-handling guard is now symmetric.
  • internal/config/resolver_test.go:
    • Renamed TestResolveMCPServerLayersCanClearAndReenableTestResolveMCPServerLayersCannotReenableUserDisabled.
    • Added TestMergeMCPStickyDisableReenable, TestResolveMCPCannotReenableUserDisabled, and TestResolveMCPUserLiftsProjectDisabled.

Test plan

  • go test ./internal/config/... passes
  • go vet ./internal/config/... clean

Summary by CodeRabbit

  • Bug Fixes
    • Improved MCP server configuration merging with clearer disable/enable precedence.
    • A server explicitly disabled in user scope is now “sticky” and cannot be re-enabled by lower-trust project settings.
    • CLI overrides can still re-enable when explicitly applied, while project scope cannot reverse a higher-priority user disable.
    • Non-disable settings continue to merge as before.
  • Tests
    • Added/updated unit and integration tests covering sticky-disable behavior across trust boundaries.

…ride user disable

A user-level MCP server disable is now sticky. The project config
(a cloned repo's .zero/config.json) cannot re-enable a server the user
disabled, and cannot disable a server the user explicitly enabled.

Previously the merge guard only blocked re-enabling a user-disabled
server, so a repo that set disabled:true could silently override a user's
explicit mcp enable <server>. The guard is now symmetric: once a
higher-trust scope has explicitly set the disabled flag, a lower-trust
layer may not override that decision in either direction. Only the CLI
override scope (canReenable=true) may lift it.

Fixes Gitlawb#512
Copilot AI review requested due to automatic review settings July 9, 2026 02:41

Copilot AI 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.

Pull request overview

This PR tightens the MCP configuration trust boundary so a lower-trust project config (.zero/config.json) cannot override a higher-trust user choice for an MCP server’s disabled state, addressing #512.

Changes:

  • Introduces a canReenable flag into MCP merge helpers to encode which config layers may override disabled.
  • Updates the disabled merge guard to be symmetric (project cannot re-enable a user-disabled server, nor disable a user-enabled server).
  • Updates/extends resolver tests to cover sticky user disable and user-over-project behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
internal/config/resolver.go Adds canReenable plumbing to MCP merge and updates the disabled merge logic to enforce the trust boundary.
internal/config/resolver_test.go Renames an existing test and adds new cases for sticky disable behavior across merge layers and entry points.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +873 to 876
if next.disabledSet {
base.disabledSet = true
}
if next.disabledSet || next.Disabled {
Comment on lines +604 to +623
func TestMergeMCPStickyDisableReenable(t *testing.T) {
disabled := MCPServerConfig{Disabled: true, disabledSet: true}

// A lower-trust (project) layer must not lift a sticky user disable.
project := MCPServerConfig{Disabled: false, disabledSet: true}
if got := mergeMCPServer(disabled, project, false); !got.Disabled {
t.Fatal("project layer re-enabled a sticky user disable")
}

// The user scope (explicit mcp enable command / CLI override) may re-enable.
override := MCPServerConfig{Disabled: false, disabledSet: true}
if got := mergeMCPServer(disabled, override, true); got.Disabled {
t.Fatal("user-scope override must be able to re-enable")
}

// A user-scope disable stacked on a project attempt still sticks.
if got := mergeMCPServer(disabled, project, false); !got.Disabled {
t.Fatal("sticky disable must survive a project re-enable attempt")
}
}
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3d98b60e-97a7-41db-b1b7-26f0d88eaef5

📥 Commits

Reviewing files that changed from the base of the PR and between 69393b1 and 67cdb45.

📒 Files selected for processing (2)
  • internal/config/resolver.go
  • internal/config/resolver_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/config/resolver.go
  • internal/config/resolver_test.go

Walkthrough

MCP server config merging now preserves explicit disables across trust boundaries unless the current layer is allowed to re-enable. The resolver wiring passes this rule through user config, project config, and CLI overrides, and the tests now cover the updated precedence behavior.

Changes

MCP sticky disable

Layer / File(s) Summary
MCP merge helper canReenable logic
internal/config/resolver.go
mergeMCPConfig and mergeMCPServer now thread canReenable through server merging and track disabledSet to distinguish explicit disabled state from inherited state.
Wiring canReenable at each config layer
internal/config/resolver.go
ResolveMCP, mergeConfig, mergeProjectConfig, and applyOverrides pass layer-specific canReenable values for user, project, and CLI override scopes.
Sticky disable test coverage
internal/config/resolver_test.go
The MCP precedence test is renamed and updated, and new unit/integration tests cover sticky disable behavior and allowed re-enable paths across scopes.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

Possibly related PRs

  • Gitlawb/zero#64: Both PRs modify MCP config merge logic in internal/config/resolver.go, with this change refining the precedence rules established there.
  • Gitlawb/zero#563: Both PRs touch mergeMCPServer and related MCP state handling in internal/config/resolver.go.

Suggested reviewers: gnanam1990, Vasanthdev2004, anandh8x, kevincodex1, jatmn

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the core change: enforcing MCP trust boundaries so project config cannot override a user disable.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/config/resolver_test.go (1)

604-623: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for project disabling an unconfigured/default server.

The current suite only exercises stickiness when the base already has an explicit user decision, which is why the mergeMCPServer ordering bug (see resolver.go Lines 873-887) slips through. A case where base has disabledSet=false and a project layer (canReenable=false) sets disabled:true — expecting the server to end up disabled — would catch it.

💚 Suggested test
func TestMergeMCPProjectCanDisableUnconfigured(t *testing.T) {
	base := MCPServerConfig{} // no prior explicit decision
	project := MCPServerConfig{Disabled: true, disabledSet: true}
	if got := mergeMCPServer(base, project, false); !got.Disabled {
		t.Fatal("project layer must be able to disable an unconfigured server")
	}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/config/resolver_test.go` around lines 604 - 623, Add a test covering
the case where mergeMCPServer starts from an unconfigured base
(disabledSet=false) and a project-layer MCPServerConfig disables it with
canReenable=false; this should end up Disabled=true. Update the resolver_test.go
coverage alongside TestMergeMCPStickyDisableReenable by adding a focused case
for the project layer disabling a default server so the mergeMCPServer ordering
bug in resolver.go is caught.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/config/resolver.go`:
- Around line 873-887: The merge logic in resolver.go is using base.disabledSet
after it has already been updated from next, which makes a project-layer
disabled: true look like an inherited higher-scope decision and prevents
disabling default servers. Snapshot the original base.disabledSet before the
merge in the disabled-set handling around the disabled flag merge, and use that
snapshot in the trust-boundary check inside the resolver merge path so project
config can still disable defaults like firecrawl. Add a regression test covering
a default server being disabled from project config.

---

Nitpick comments:
In `@internal/config/resolver_test.go`:
- Around line 604-623: Add a test covering the case where mergeMCPServer starts
from an unconfigured base (disabledSet=false) and a project-layer
MCPServerConfig disables it with canReenable=false; this should end up
Disabled=true. Update the resolver_test.go coverage alongside
TestMergeMCPStickyDisableReenable by adding a focused case for the project layer
disabling a default server so the mergeMCPServer ordering bug in resolver.go is
caught.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: baf62d15-ecea-45eb-8862-73a8fc213264

📥 Commits

Reviewing files that changed from the base of the PR and between a7cfb99 and 69393b1.

📒 Files selected for processing (2)
  • internal/config/resolver.go
  • internal/config/resolver_test.go

Comment thread internal/config/resolver.go
Copilot review: the disabled trust-boundary guard read base.disabledSet
after it had already been OR-ed with next.disabledSet, so a project
layer could never disable a default-enabled server (the guard fired on
the project's own flag instead of the higher-trust scope's decision).
Capture base.disabledSet/base.Disabled before folding in next, and
evaluate the boundary against those prior values.

Adds regression coverage: a project layer may disable a default-enabled
server when no higher-trust scope set disabled, and an empty project
layer must not flip an unconfigured server to disabled.

Co-Authored-By: Copilot <copilot@github.com>

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM.

User-level MCP disable (and explicit enable) should be sticky against project config. Making the guard symmetric with canReenable on the project layer is the right trust boundary: repo cannot re-enable what the user disabled, and cannot flip an explicit user enable either. CLI overrides still can.

Tests cover sticky disable, project re-enable blocked, user enable sticky vs project disable, and project still able to disable a default-enabled server. All CI green.

One merge note: #597 also touches MCP project merge. If both land, the second one may need a small rebase so sticky-disable and the credential/target-change guard both apply. Not blocking this PR on its own.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcp: project config can re-enable user-disabled MCP servers

3 participants