Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 94 additions & 2 deletions notebook_intelligence/claude_mcp_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from __future__ import annotations

import asyncio
import dataclasses
import json
import logging
from dataclasses import dataclass, field
Expand All @@ -30,6 +31,7 @@
run_claude_cli,
validate_scope,
)
from notebook_intelligence.config import _atomic_write_json

log = logging.getLogger(__name__)

Expand All @@ -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.<cwd>.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 {
Expand All @@ -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,
}


Expand Down Expand Up @@ -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: ``<cwd>/.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:
Expand Down Expand Up @@ -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.<cwd>.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:
Expand Down
34 changes: 34 additions & 0 deletions notebook_intelligence/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
is_locked,
resolve_feature_flag,
)
from notebook_intelligence._claude_cli import validate_scope
from notebook_intelligence.claude import ClaudeCodeChatParticipant, fetch_claude_models
from notebook_intelligence.claude_mcp_manager import ClaudeMCPManager
from notebook_intelligence.plugin_manager import PluginManager
Expand Down Expand Up @@ -933,6 +934,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)
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

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."""
Expand Down
19 changes: 18 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export interface IClaudeMCPServer {
env: Record<string, string>;
url: string;
headers: Record<string, string>;
disabledForWorkspace: boolean;
}

export interface IClaudeMCPAddInput {
Expand Down Expand Up @@ -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)
};
}

Expand Down Expand Up @@ -917,6 +919,21 @@ export class NBIAPI {
});
}

static async setClaudeMCPServerDisabled(
name: string,
scope: ClaudeMCPScope,
disabled: boolean
): Promise<IClaudeMCPServer> {
const data = await requestAPI<any>(
`claude-mcp/${scope}/${encodeURIComponent(name)}`,
{
method: 'PATCH',
body: JSON.stringify({ disabled_for_workspace: disabled })
}
);
return claudeMCPServerFromWire(data.server);
}

static async listPlugins(): Promise<IPluginInfo[]> {
const data = await requestAPI<any>('plugins');
return Array.isArray(data?.plugins) ? (data.plugins as IPluginInfo[]) : [];
Expand Down
62 changes: 59 additions & 3 deletions src/components/claude-mcp-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export function SettingsPanelComponentClaudeMCP(_props: any): JSX.Element {
const [pendingRemoval, setPendingRemoval] = useState<IClaudeMCPServer | null>(
null
);
const [pendingToggle, setPendingToggle] = useState<IClaudeMCPServer | null>(
null
);

const refresh = async () => {
setLoading(true);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -126,7 +145,9 @@ export function SettingsPanelComponentClaudeMCP(_props: any): JSX.Element {
servers={grouped[scope]}
loading={loading}
pendingRemoval={pendingRemoval}
pendingToggle={pendingToggle}
onRemove={handleRemove}
onToggleDisabled={handleToggleDisabled}
/>
))}

Expand All @@ -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 (
<div className="nbi-skills-section">
Expand All @@ -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)}
/>
))
)}
Expand All @@ -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 (
<div className="nbi-skill-row">
<div
className={`nbi-skill-row${disabled ? ' nbi-skill-row-disabled' : ''}`}
>
<div className="nbi-skill-row-main">
<div className="nbi-skill-row-name">{srv.name}</div>
<div className="nbi-skill-row-name">
{srv.name}
{disabled && (
<span className="nbi-skill-row-badge">Disabled for workspace</span>
)}
</div>
<div className="nbi-skill-row-description">
<code>{srv.transport}</code>
{summary && <span> {summary}</span>}
{summary && <span>: {summary}</span>}
</div>
</div>
<div className="nbi-skill-row-actions" onClick={e => e.stopPropagation()}>
<button
className="jp-Dialog-button jp-mod-reject jp-mod-styled"
onClick={props.onToggleDisabled}
disabled={props.toggling}
title={toggleTitle}
>
<div className="jp-Dialog-buttonLabel">{toggleLabel}</div>
</button>
<button
className="jp-Dialog-button jp-mod-reject jp-mod-styled"
onClick={props.onRemove}
Expand Down
26 changes: 26 additions & 0 deletions style/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -1735,6 +1735,25 @@ svg.access-token-warning {
font-size: var(--jp-ui-font-size0);
}

.nbi-skill-row-disabled {
border-style: dashed;
}

.nbi-skill-row-disabled .nbi-skill-row-name {
font-style: italic;
}

.nbi-skill-row-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 8px;
background-color: var(--jp-layout-color3);
color: var(--jp-ui-font-color1);
font-weight: 500;
font-size: var(--jp-ui-font-size0);
text-transform: none;
}

/* Inline row actions (reveal on hover/focus-within) */
.nbi-skill-row-actions {
flex: 0 0 auto;
Expand All @@ -1745,6 +1764,13 @@ svg.access-token-warning {
transition: opacity 0.1s ease-in-out;
}

/* When a row is disabled-for-workspace, the user needs an immediate way to
re-enable it. Override the hover-only reveal so the toggle button is
always visible. */
.nbi-skill-row-disabled .nbi-skill-row-actions {
opacity: 1;
}

.nbi-skill-row:hover .nbi-skill-row-actions,
.nbi-skill-row:focus-within .nbi-skill-row-actions,
.nbi-skill-row:focus .nbi-skill-row-actions {
Expand Down
Loading
Loading