From ef4989b55c1b254f00bb08d70a3be209c42e16b8 Mon Sep 17 00:00:00 2001 From: PJ Doland Date: Sun, 17 May 2026 08:56:38 -0400 Subject: [PATCH 1/2] feat(claude-mcp): disable MCP servers per workspace Adds a per-row "Disable for workspace" toggle on the Claude MCP settings panel that writes to `projects..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 #283 --- notebook_intelligence/claude_mcp_manager.py | 96 +++++++++++- notebook_intelligence/extension.py | 33 ++++ src/api.ts | 19 ++- src/components/claude-mcp-panel.tsx | 62 +++++++- style/base.css | 26 ++++ tests/test_claude_mcp_manager.py | 157 ++++++++++++++++++++ 6 files changed, 387 insertions(+), 6 deletions(-) diff --git a/notebook_intelligence/claude_mcp_manager.py b/notebook_intelligence/claude_mcp_manager.py index fa8408d3..33c23af3 100644 --- a/notebook_intelligence/claude_mcp_manager.py +++ b/notebook_intelligence/claude_mcp_manager.py @@ -18,6 +18,7 @@ from __future__ import annotations import asyncio +import dataclasses import json import logging from dataclasses import dataclass, field @@ -30,6 +31,7 @@ run_claude_cli, validate_scope, ) +from notebook_intelligence.config import _atomic_write_json log = logging.getLogger(__name__) @@ -49,6 +51,11 @@ class ClaudeMCPServer: env: dict[str, str] = field(default_factory=dict) # stdio url: str = "" # sse | http headers: dict[str, str] = field(default_factory=dict) # sse | http + # Workspace-level disable, sourced from + # `~/.claude.json` → projects..disabledMcpServers[]. Independent of + # scope: the list is just server names, so the same flag applies to a + # `user`-scope server even though it's a workspace-level opt-out. + disabled_for_workspace: bool = False def to_dict(self) -> dict: return { @@ -60,6 +67,7 @@ def to_dict(self) -> dict: "env": dict(self.env), "url": self.url, "headers": dict(self.headers), + "disabled_for_workspace": self.disabled_for_workspace, } @@ -138,16 +146,30 @@ def list_servers(self) -> list[ClaudeMCPServer]: # Local scope is keyed by absolute working-dir path under `projects`. projects = user_doc.get("projects") or {} - local_block = (projects.get(str(self._working_dir)) or {}).get("mcpServers") - servers.extend(_gather_from_dict(local_block, "local")) + project_block = projects.get(str(self._working_dir)) or {} + servers.extend(_gather_from_dict(project_block.get("mcpServers"), "local")) # Project scope: ``/.mcp.json`` with top-level ``mcpServers``. project_doc = _read_json(self._working_dir / ".mcp.json") or {} servers.extend(_gather_from_dict(project_doc.get("mcpServers"), "project")) + disabled_names = self._read_disabled_names(project_block) + if disabled_names: + servers = [ + dataclasses.replace(s, disabled_for_workspace=s.name in disabled_names) + for s in servers + ] + servers.sort(key=lambda s: (s.name, s.scope)) return servers + @staticmethod + def _read_disabled_names(project_block: dict) -> set[str]: + raw = project_block.get("disabledMcpServers") if isinstance(project_block, dict) else None + if not isinstance(raw, list): + return set() + return {str(item) for item in raw if isinstance(item, str)} + def get_server(self, name: str, scope: ClaudeMCPScope) -> Optional[ClaudeMCPServer]: for srv in self.list_servers(): if srv.name == name and srv.scope == scope: @@ -239,6 +261,76 @@ async def add_server( ) return srv + async def set_server_disabled(self, name: str, disabled: bool) -> ClaudeMCPServer: + """Toggle the workspace-level disable flag for ``name``. + + Edits ``~/.claude.json`` in place. The flag is workspace-wide (a single + list of names under the current working directory's ``projects`` + entry), so scope is not part of the key; passing the same name from a + user/local/project row produces the same write. + """ + if not name: + raise ValueError("Missing server name") + async with self._write_lock: + self._mutate_disabled_list(name, disabled) + matches = [s for s in self.list_servers() if s.name == name] + if matches: + return matches[0] + # No definition of `name` is visible from the current cwd. The write + # still succeeded (a future definition would inherit the disabled + # flag), so synthesize a minimal record reflecting the new state. + return ClaudeMCPServer( + name=name, + scope="user", + transport="stdio", + disabled_for_workspace=disabled, + ) + + def _mutate_disabled_list(self, name: str, disabled: bool) -> None: + """Atomically update ``projects..disabledMcpServers`` in + ``~/.claude.json``. Preserves all other keys verbatim.""" + path = self._user_config_path + doc: dict + if path.exists(): + try: + with path.open("r", encoding="utf-8") as fp: + doc = json.load(fp) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError( + f"Could not parse {path}; refusing to overwrite: {exc}" + ) from exc + if not isinstance(doc, dict): + raise ValueError( + f"{path} root must be a JSON object; got {type(doc).__name__}" + ) + else: + doc = {} + + projects = doc.setdefault("projects", {}) + if not isinstance(projects, dict): + raise ValueError(f"{path}: `projects` must be an object") + cwd_key = str(self._working_dir) + project_block = projects.setdefault(cwd_key, {}) + if not isinstance(project_block, dict): + raise ValueError(f"{path}: `projects.{cwd_key}` must be an object") + current = project_block.get("disabledMcpServers") + if isinstance(current, list): + disabled_list = [str(x) for x in current if isinstance(x, str)] + else: + disabled_list = [] + present = name in disabled_list + if disabled and not present: + disabled_list.append(name) + elif not disabled and present: + disabled_list = [x for x in disabled_list if x != name] + project_block["disabledMcpServers"] = disabled_list + + # Reuse the symlink/mode/parent-dir-fsync atomic-write helper from + # config.py so all on-disk config edits share the same durability + # contract. + path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write_json(str(path), doc) + async def remove_server(self, name: str, scope: ClaudeMCPScope) -> None: validate_scope(scope) if not name: diff --git a/notebook_intelligence/extension.py b/notebook_intelligence/extension.py index 61d8b8af..8bf04953 100644 --- a/notebook_intelligence/extension.py +++ b/notebook_intelligence/extension.py @@ -933,6 +933,39 @@ async def delete(self, scope, name): except (FileNotFoundError, TimeoutError, ValueError) as e: self._error(e) + @tornado.web.authenticated + async def patch(self, scope, name): + # `scope` is part of the URL for symmetry with GET/DELETE but the + # workspace-disable list is a single flat array of names (not + # 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) + except ValueError as e: + self._error(e) + return + data = self._parse_json_body() + if data is None: + return + if "disabled_for_workspace" not in data: + self.set_status(400) + self.finish(json.dumps( + {"error": "Missing `disabled_for_workspace` in request body"} + )) + return + raw = data["disabled_for_workspace"] + if not isinstance(raw, bool): + self.set_status(400) + self.finish(json.dumps( + {"error": "`disabled_for_workspace` must be a JSON boolean"} + )) + return + try: + srv = await self.manager.set_server_disabled(name=name, disabled=raw) + self.finish(json.dumps({"server": srv.to_dict()})) + except (FileNotFoundError, TimeoutError, ValueError) as e: + self._error(e) + class PluginsBaseHandler(PolicyGatedHandler): """Shared helpers + policy gate for plugin endpoints.""" diff --git a/src/api.ts b/src/api.ts index a9d6b772..75cb8fd1 100644 --- a/src/api.ts +++ b/src/api.ts @@ -101,6 +101,7 @@ export interface IClaudeMCPServer { env: Record; url: string; headers: Record; + disabledForWorkspace: boolean; } export interface IClaudeMCPAddInput { @@ -132,7 +133,8 @@ function claudeMCPServerFromWire(wire: any): IClaudeMCPServer { ? Object.fromEntries( Object.entries(wire.headers).map(([k, v]) => [String(k), String(v)]) ) - : {} + : {}, + disabledForWorkspace: Boolean(wire?.disabled_for_workspace) }; } @@ -917,6 +919,21 @@ export class NBIAPI { }); } + static async setClaudeMCPServerDisabled( + name: string, + scope: ClaudeMCPScope, + disabled: boolean + ): Promise { + const data = await requestAPI( + `claude-mcp/${scope}/${encodeURIComponent(name)}`, + { + method: 'PATCH', + body: JSON.stringify({ disabled_for_workspace: disabled }) + } + ); + return claudeMCPServerFromWire(data.server); + } + static async listPlugins(): Promise { const data = await requestAPI('plugins'); return Array.isArray(data?.plugins) ? (data.plugins as IPluginInfo[]) : []; diff --git a/src/components/claude-mcp-panel.tsx b/src/components/claude-mcp-panel.tsx index 751853d5..7068bdfa 100644 --- a/src/components/claude-mcp-panel.tsx +++ b/src/components/claude-mcp-panel.tsx @@ -28,6 +28,9 @@ export function SettingsPanelComponentClaudeMCP(_props: any): JSX.Element { const [pendingRemoval, setPendingRemoval] = useState( null ); + const [pendingToggle, setPendingToggle] = useState( + null + ); const refresh = async () => { setLoading(true); @@ -66,6 +69,22 @@ export function SettingsPanelComponentClaudeMCP(_props: any): JSX.Element { } }; + const handleToggleDisabled = async (srv: IClaudeMCPServer) => { + setPendingToggle(srv); + try { + await NBIAPI.setClaudeMCPServerDisabled( + srv.name, + srv.scope, + !srv.disabledForWorkspace + ); + await refresh(); + } catch (e: any) { + setError(`Failed to update workspace state: ${e?.message ?? e}`); + } finally { + setPendingToggle(null); + } + }; + const handleAddSubmit = async (input: IClaudeMCPAddInput) => { // Errors are rendered inside the dialog so they're not hidden behind the // modal backdrop; rethrow so the dialog can keep itself open. @@ -126,7 +145,9 @@ export function SettingsPanelComponentClaudeMCP(_props: any): JSX.Element { servers={grouped[scope]} loading={loading} pendingRemoval={pendingRemoval} + pendingToggle={pendingToggle} onRemove={handleRemove} + onToggleDisabled={handleToggleDisabled} /> ))} @@ -145,7 +166,9 @@ function ClaudeMCPScopeSection(props: { servers: IClaudeMCPServer[]; loading: boolean; pendingRemoval: IClaudeMCPServer | null; + pendingToggle: IClaudeMCPServer | null; onRemove: (srv: IClaudeMCPServer) => void; + onToggleDisabled: (srv: IClaudeMCPServer) => void; }) { return (
@@ -168,7 +191,12 @@ function ClaudeMCPScopeSection(props: { props.pendingRemoval?.name === srv.name && props.pendingRemoval?.scope === srv.scope } + toggling={ + props.pendingToggle?.name === srv.name && + props.pendingToggle?.scope === srv.scope + } onRemove={() => props.onRemove(srv)} + onToggleDisabled={() => props.onToggleDisabled(srv)} /> )) )} @@ -179,23 +207,51 @@ function ClaudeMCPScopeSection(props: { function ClaudeMCPRow(props: { srv: IClaudeMCPServer; removing: boolean; + toggling: boolean; onRemove: () => void; + onToggleDisabled: () => void; }) { const { srv } = props; const summary = srv.transport === 'stdio' ? [srv.command, ...srv.args].filter(Boolean).join(' ') : srv.url; + const disabled = srv.disabledForWorkspace; + const toggleLabel = disabled + ? props.toggling + ? 'Enabling…' + : 'Enable for workspace' + : props.toggling + ? 'Disabling…' + : 'Disable for workspace'; + const toggleTitle = disabled + ? 'Re-enable this server for the current Jupyter workspace' + : 'Hide this server from Claude in the current Jupyter workspace (other workspaces unaffected)'; return ( -
+
-
{srv.name}
+
+ {srv.name} + {disabled && ( + Disabled for workspace + )} +
{srv.transport} - {summary && — {summary}} + {summary && : {summary}}
e.stopPropagation()}> +