Skip to content

feat(claude-mcp): disable MCP servers per workspace#286

Merged
mbektas merged 2 commits into
plmbr:mainfrom
pjdoland:feat/283-disable-claude-mcp
May 17, 2026
Merged

feat(claude-mcp): disable MCP servers per workspace#286
mbektas merged 2 commits into
plmbr:mainfrom
pjdoland:feat/283-disable-claude-mcp

Conversation

@pjdoland
Copy link
Copy Markdown
Collaborator

Summary

Adds a per-row Enable/Disable for Workspace toggle on the Claude MCP settings panel. The toggle writes to the same projects.<cwd>.disabledMcpServers[] array in ~/.claude.json that Claude Code itself reads, so a server can be opted out of one Jupyter workspace without removing it from the user scope or affecting other workspaces.

Solution

The disable list is workspace-wide (a flat array of server names keyed by the current working directory), not per-scope, so a user-scope server can still be opted out of a single workspace.

Backend:

  • ClaudeMCPServer gains a disabled_for_workspace: bool field, populated during list_servers() from the ~/.claude.json projects.<cwd>.disabledMcpServers[] entry for the current Jupyter root.
  • New ClaudeMCPManager.set_server_disabled(name, disabled) reads ~/.claude.json, mutates the disabled list under the current working dir, and writes back via config._atomic_write_json so the on-disk edit shares the same symlink-preserving, mode-preserving, parent-dir-fsync durability contract as the rest of NBI's config writes.
  • New PATCH /notebook-intelligence/claude-mcp/<scope>/<name> accepts {"disabled_for_workspace": <bool>}. The handler validates scope and rejects non-bool payloads (stringly-typed "false" no longer flips the row).

Frontend:

  • NBIAPI.setClaudeMCPServerDisabled(name, scope, disabled) mirrors the wire contract.
  • The settings panel renders disabled rows with a dashed border, italic name, and a "Disabled for workspace" badge. The toggle button is forced visible on disabled rows so users don't have to hover to re-enable.

Testing

  • pytest tests/ (718 passed) including 9 new tests in TestWorkspaceDisable: list-time flag stamping, per-cwd scoping, write idempotency, sibling-key preservation on partial updates, missing-config-file creation, and corrupt-config rejection.
  • jlpm tsc --noEmit, jlpm lint:check, jlpm jest (131 passed) all green.
  • Manually verified the file-on-disk shape: toggling preserves unrelated top-level keys (telemetry, etc.) and sibling project blocks.

Risks and follow-ups

  • Concurrency with Claude CLI: a Claude CLI write between our json.load and os.replace would be clobbered. The window is milliseconds and the toggle is user-initiated. The same hazard exists for claude mcp add, which the existing manager already shells out to.
  • The PATCH URL captures scope for symmetry with GET/DELETE; the value is validated but ignored on the write path since the disable list is not per-scope. A future per-scope disable list would be a non-breaking extension.
  • If the toggled server has no canonical definition visible from the current cwd, the response synthesizes a minimal record (the panel's refresh() then re-reads the truth).

Closes #283

Adds a per-row "Disable for workspace" toggle on the Claude MCP settings
panel that writes to `projects.<cwd>.disabledMcpServers[]` in
`~/.claude.json`, matching the scope Claude Code itself reads from. Other
Jupyter workspaces are untouched: the disable list is keyed by the
current working directory.

The toggle is workspace-wide (not per-scope), so a server defined in
user scope can still be opted out of a single workspace without removing
it from Claude. Disabled rows render with a dashed border, italic name,
and a "Disabled for workspace" badge; the toggle button is always
visible on those rows so users don't need to hover to re-enable.

Backend writes go through `config._atomic_write_json` to share the
symlink/mode/parent-dir-fsync durability contract with the rest of NBI's
on-disk edits. The PATCH endpoint validates both scope and the boolean
payload (a stringly-typed "false" no longer flips the row).

Skipped: per-scope disable lists (Claude itself doesn't model them) and
local-state patching after toggle (refresh() already runs and the cost
is one HTTP round-trip on a click).

Closes plmbr#283
# per-scope), so any of user/project/local resolves to the same write.
# We still validate it so a malformed URL fails fast.
try:
validate_scope(scope)
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.

validate_scope import is missing

@mbektas mbektas added this to the 5.0.x milestone May 17, 2026
The PATCH handler added in the previous commit called validate_scope
without importing it, so any real PATCH dispatch raised NameError and
500'd. Caught by mbektas in review.

Adds two layers of regression coverage so the same import-miss class of
bug can't repeat:

- TestPatchEndpointDispatches in tests/test_claude_mcp_manager.py
  drives the real ClaudeMCPDetailHandler under tornado.testing with
  APIHandler.prepare + current_user stubbed. Verified to fail with the
  exact NameError when the import is removed.
- ui-tests/tests/claude-mcp-patch.spec.ts hits the running JupyterLab
  server's /notebook-intelligence/claude-mcp/<scope>/<name> PATCH route
  from a browser context, so routing, auth middleware, and prepare()
  chain are all exercised end-to-end.

Both new test suites pass with the fix and fail without it. The
pre-existing chat-sidebar Galata test is unrelated and fails on
upstream/main as well.
@pjdoland
Copy link
Copy Markdown
Collaborator Author

Good catch, fixed in 05828e8. Added two regression tests so this class of import-miss can't slip past CI again:

  1. TestPatchEndpointDispatches (tests/test_claude_mcp_manager.py) drives ClaudeMCPDetailHandler through tornado.testing with auth stubbed. Confirmed it fails with the exact NameError when the import is removed.
  2. ui-tests/tests/claude-mcp-patch.spec.ts hits the running JupyterLab server's PATCH route from a browser context via Galata, so routing, auth middleware, and the prepare() chain are all exercised end-to-end.

Both pass with the fix and fail without it.

@pjdoland pjdoland added the enhancement New feature or request label May 17, 2026
@mbektas mbektas merged commit 918caf0 into plmbr:main May 17, 2026
4 of 5 checks passed
pjdoland added a commit to pjdoland/notebook-intelligence that referenced this pull request May 22, 2026
Promotes the [Unreleased] CHANGELOG snapshot to [5.0.0] - 2026-05-22
and expands it to cover everything merged into upstream/main after
PR plmbr#287's docs refresh. Bumps package.json to 5.0.0.

CHANGELOG additions cover the post-plmbr#287 surface:

- Settings tabs: plugin marketplace picker (plmbr#284), plugin marketplace
  details + Update button (plmbr#303), per-workspace MCP disable (plmbr#286),
  JSON-paste path in Add MCP server (plmbr#285).
- Launchers: hide-with-policy (plmbr#288), brand icons for Codex / opencode
  (plmbr#325, plmbr#333), per-launch directory picker (plmbr#332).
- Chat sidebar and agentic UX: workspace @-mention in Claude mode
  (plmbr#327), reload-open-files-on-disk (plmbr#330), steered system prompt
  away from over-eager notebook creation (plmbr#336).
- Skills: multi-manifest support (plmbr#321), tracks-upstream for user-
  imported skills (plmbr#322), HTTP kill switch for the reconciler (plmbr#291).
- Accessibility: full sub-section covering plmbr#305-plmbr#320.
- Security: shell-tool sandbox (plmbr#290), Claude UI-bridge sandbox (plmbr#323),
  0o600 on encrypted token (plmbr#293), env-secret scrubbing (plmbr#295), MCP
  config shape validation (plmbr#299), XSS allowlist (plmbr#296), Copilot WS
  auth + origin (plmbr#301), GHE host detection (plmbr#292), fastmcp -> mcp SDK
  swap (plmbr#324).
- Fixed: session listing unification (plmbr#310), session preview unwrap
  (plmbr#331), down-area runtime throw (plmbr#330 follow-up), WS message-handler
  leak (plmbr#294).
- Removed: fastmcp dependency, history.jsonl session gate.

Adds a Migration note covering the five behavior changes operators
should review before upgrading from 4.x: fastmcp swap, path
sandboxes, history.jsonl gate removal, workspace @-mention pointer
shape, and the Copilot WebSocket auth/origin tightening.

Two reviewer rounds (six personas each) applied:
- Round 1 caught security overclaims (plmbr#293, plmbr#299, plmbr#323), the
  plmbr#284/plmbr#303 mis-attribution, missing migration note, 3 em dashes,
  and the stale `fastmcp==2.x.*` recommendation in the admin guide.
- Round 2 caught the missing plmbr#301 migration bullet, missing version-
  matrix 5.0.x row, missing README TOC entry, and a couple of style
  nits (sub-heading overpromise, orphan bullet).

Skipped (deferred to future PRs):
- README first-run tour mention.
- Admin guide HTTP kill-switch row in Failure-modes table.
- Terminal drag-drop trust-model precision update after plmbr#327.
- Cipher description nit in plmbr#293 (Fernet AES-128-CBC+HMAC, not
  AES-GCM).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support disabling Claude MCP servers

2 participants