Skip to content

Commit e4b5da8

Browse files
committed
fix(claude): honor CLAUDE_CONFIG_DIR for user-scope MCP config and the plugin cache
The CLI relocates .claude.json to $CLAUDE_CONFIG_DIR/.claude.json when the override is set, but the MCP manager kept reading (and writing disabledMcpServers to) $HOME/.claude.json while CLI-mediated writes via 'claude mcp add' inherited the env and landed in the relocated file, so reads and writes diverged on override deployments. The plugin cache fallback similarly pointed at ~/.claude/plugins instead of the relocated cache. The .claude.json resolver is a private helper rather than a reuse of util.get_claude_config_dir because its default genuinely differs: the file lives in the home dir itself, not inside ~/.claude. The plugins fallback does route through the shared helper; the CLI gives CLAUDE_CODE_PLUGIN_CACHE_DIR precedence over the config dir and NBI matches that, verified against the CLI bundle and a sandboxed 'claude mcp add' run. Test hardening that the change made necessary: the unittest-style PATCH suite patches Path.home but pytest fixtures don't reach it, so a developer's exported CLAUDE_CONFIG_DIR would have sent the endpoint's write into their real relocated .claude.json; setUp now snapshots and scrubs the env. The Playwright webServer env drops the variable for the same reason, deleting it rather than blanking it because the CLI treats an empty string as a set (degenerate) config dir. Also dates 5.1.0 in the changelog (released 2026-06-08) and adds the missing compare links. Fixes #375
1 parent 85908ce commit e4b5da8

8 files changed

Lines changed: 140 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ For each release we list user-facing changes grouped as **Added**, **Changed**,
1313
### Fixed
1414

1515
- **Session history follows `CLAUDE_CONFIG_DIR`** (#373). The chat-sidebar resume picker and the launcher tile's session list always read transcripts from `~/.claude/projects`, so both came up empty when the Claude CLI was configured with `CLAUDE_CONFIG_DIR` and wrote its transcripts elsewhere. The session listing now resolves the CLI's config dir the same way the skills and spinner-verbs paths already did.
16+
- **User-scope MCP config and the plugin cache follow `CLAUDE_CONFIG_DIR`** (#375). The MCP management tab read user-scope servers from `~/.claude.json` even though the CLI relocates that file to `$CLAUDE_CONFIG_DIR/.claude.json` when the override is set (so reads and CLI-mediated writes diverged), and the Plugins panel's cache fallback pointed at `~/.claude/plugins` instead of the relocated cache. Both now resolve the CLI's actual locations; `CLAUDE_CODE_PLUGIN_CACHE_DIR` still wins for the plugin cache when set.
1617

17-
## [5.1.0] - UNRELEASED
18+
## [5.1.0] - 2026-06-08
1819

1920
5.1.0 builds on 5.0.x with a focus on Claude-mode agent visibility. Tool calls the agent runs now render as persistent status cards with inline diffs and collapsible grouping, the generating indicator can cycle custom verbs, and cancelling a turn tears down the whole process tree the agent spawned instead of leaking it. It also adds two opt-in security guardrails (an MCP stdio-command allowlist and a default-token-password check on shared filesystems) and an always-visible mode for chat feedback. No traitlet, env-var, REST route, or on-disk-format renames or removals; every new admin surface is opt-in and listed below.
2021

@@ -378,7 +379,8 @@ A multi-PR accessibility pass landed across most NBI surfaces. Together these ma
378379
- Settings UI restructured around Claude vs default mode.
379380
- WebSocket connection reliability improvements.
380381

381-
[unreleased]: https://github.com/plmbr/notebook-intelligence/compare/v5.0.1...HEAD
382+
[unreleased]: https://github.com/plmbr/notebook-intelligence/compare/v5.1.0...HEAD
383+
[5.1.0]: https://github.com/plmbr/notebook-intelligence/compare/v5.0.1...v5.1.0
382384
[5.0.1]: https://github.com/plmbr/notebook-intelligence/compare/v5.0.0...v5.0.1
383385
[5.0.0]: https://github.com/plmbr/notebook-intelligence/compare/v4.8.0...v5.0.0
384386
[4.8.0]: https://github.com/plmbr/notebook-intelligence/compare/v4.7.0...v4.8.0

docs/admin-guide.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Manual edits to `config.json` while JupyterLab is running require a JupyterLab r
5757
| `<env-prefix>/share/jupyter/nbi/` | No (image) | Org-wide base config. Bake into your container image. |
5858
| Project-scope `<project>/.claude/skills/` | Per project | Lives in the user's working directory. Persists if the working directory does. |
5959

60-
For Kubeflow or KubeSpawner: mount the user's home directory on a PVC and ensure `~/.jupyter` and `~/.claude` are inside that mount. Anything else (`/tmp`, `~/.cache`) can be ephemeral.
60+
For Kubeflow or KubeSpawner: mount the user's home directory on a PVC and ensure `~/.jupyter` and `~/.claude` are inside that mount (or `$CLAUDE_CONFIG_DIR`, if you point the Claude CLI elsewhere). Anything else (`/tmp`, `~/.cache`) can be ephemeral.
6161

6262
---
6363

@@ -473,7 +473,7 @@ Force-off:
473473
- Hides the Claude-mode **MCP Servers** tab in the Settings panel (visible only when Claude mode is on and the `claude` CLI is available).
474474
- Returns HTTP 403 from every `/notebook-intelligence/claude-mcp/*` route.
475475

476-
The Claude-mode tab is **independent** of the existing non-Claude **MCP Servers** tab. The former wraps Claude Code's own config (`~/.claude.json` and project `.mcp.json`); the latter manages NBI's own MCP servers used by the non-Claude chat path. They never appear at the same time the non-Claude tab is hidden when Claude mode is on, and the Claude-mode tab is hidden when it's off.
476+
The Claude-mode tab is **independent** of the existing non-Claude **MCP Servers** tab. The former wraps Claude Code's own config (`~/.claude.json`, relocated to `$CLAUDE_CONFIG_DIR/.claude.json` when that env var is set, and project `.mcp.json`); the latter manages NBI's own MCP servers used by the non-Claude chat path. They never appear at the same time; the non-Claude tab is hidden when Claude mode is on, and the Claude-mode tab is hidden when it's off.
477477

478478
Reads come from Claude's JSON config files directly (fast, no health checks). Writes (add / remove) shell out to `claude mcp add` / `claude mcp remove` so Claude remains the source of truth for any side effects (project-trust prompts, OAuth bookkeeping).
479479

@@ -515,7 +515,7 @@ c.NotebookIntelligence.claude_plugins_management_policy = "force-off"
515515

516516
Or via env: `NBI_CLAUDE_PLUGINS_MANAGEMENT_POLICY=force-off`.
517517

518-
Force-off hides the **Plugins** tab and returns 403 from every `/notebook-intelligence/plugins/*` route. The tab is otherwise visible only when Claude mode is on and the `claude` CLI is available. Both reads (`claude plugin list --json`) and writes (`claude plugin install` / `uninstall` / `enable` / `disable` / `marketplace add` / `marketplace remove`) shell out to the Claude CLI; Claude owns the plugin state under `~/.claude/plugins/`.
518+
Force-off hides the **Plugins** tab and returns 403 from every `/notebook-intelligence/plugins/*` route. The tab is otherwise visible only when Claude mode is on and the `claude` CLI is available. Both reads (`claude plugin list --json`) and writes (`claude plugin install` / `uninstall` / `enable` / `disable` / `marketplace add` / `marketplace remove`) shell out to the Claude CLI; Claude owns the plugin state under `~/.claude/plugins/` (`$CLAUDE_CONFIG_DIR/plugins/` when that env var is set); NBI reads the same location.
519519

520520
> **Blast radius.** Force-off only kills the _management UI_ — already-installed plugins keep loading inside Claude Code sessions because Claude's plugin loader doesn't consult NBI's policy. To stop existing plugins from loading, you'd need to remove them on disk or disable them via the `claude plugin disable` CLI before flipping the policy. Force-off prevents user-driven add/remove/enable/disable through NBI; that's the contract.
521521

notebook_intelligence/claude_mcp_manager.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
- Local scope: ``~/.claude.json`` → ``projects.<cwd>.mcpServers``
99
- Project scope: ``<cwd>/.mcp.json``
1010
11+
When ``CLAUDE_CONFIG_DIR`` is set, the CLI relocates ``.claude.json`` to
12+
``$CLAUDE_CONFIG_DIR/.claude.json`` and the reads follow it there.
13+
1114
Writes go through ``claude mcp add`` / ``claude mcp remove`` so Claude
1215
remains the source of truth for any side effects (project-trust prompts,
1316
oauth bookkeeping, etc.). The file-based reads are decoupled from the CLI's
@@ -21,6 +24,7 @@
2124
import dataclasses
2225
import json
2326
import logging
27+
import os
2428
from dataclasses import dataclass, field
2529
from pathlib import Path
2630
from typing import Iterable, Literal, Optional
@@ -127,6 +131,21 @@ def _gather_from_dict(
127131
return out
128132

129133

134+
def _claude_user_config_path() -> Path:
135+
"""Locate the CLI's ``.claude.json``.
136+
137+
Unlike skills and transcripts, the default lives in the home dir itself
138+
rather than inside ``~/.claude``, so this can't reuse
139+
``util.get_claude_config_dir``'s fallback: the CLI keeps the file at
140+
``$HOME/.claude.json`` normally but relocates it to
141+
``$CLAUDE_CONFIG_DIR/.claude.json`` when the override is set.
142+
"""
143+
override = os.environ.get("CLAUDE_CONFIG_DIR")
144+
if override:
145+
return Path(override) / ".claude.json"
146+
return Path.home() / ".claude.json"
147+
148+
130149
class ClaudeMCPManager:
131150
# Class-level so the lock is shared across all ClaudeMCPManager instances
132151
# within this process. Handlers construct a fresh manager per request,
@@ -142,7 +161,7 @@ def __init__(
142161
stdio_command_allowlist: Optional[Iterable[str]] = None,
143162
):
144163
self._working_dir = Path(working_dir) if working_dir else Path.cwd()
145-
self._user_config_path = Path.home() / ".claude.json"
164+
self._user_config_path = _claude_user_config_path()
146165
# An empty allowlist means "no enforcement"; the default keeps
147166
# per-user deployments permissive. Admins thread their pinned
148167
# regex list in via ``stdio_command_allowlist``.

notebook_intelligence/plugin_manager.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
"""Wrapper around Claude Code's `claude plugin` CLI.
44
5-
Plugin state is owned by Claude Code itself (under `~/.claude/plugins/`).
5+
Plugin state is owned by Claude Code itself (under `~/.claude/plugins/`,
6+
or `$CLAUDE_CONFIG_DIR/plugins/` when that env var is set).
67
NBI shells out for both reads and writes:
78
89
- `claude plugin list --json` → JSON array of installed plugins
@@ -35,7 +36,7 @@
3536
run_claude_cli,
3637
validate_scope,
3738
)
38-
from notebook_intelligence.util import resolve_github_token
39+
from notebook_intelligence.util import get_claude_config_dir, resolve_github_token
3940

4041
log = logging.getLogger(__name__)
4142

@@ -244,7 +245,7 @@ def _claude_plugins_root() -> Path:
244245
configured = os.environ.get(CLAUDE_PLUGIN_CACHE_DIR_ENV)
245246
if configured:
246247
return Path(configured).expanduser()
247-
return Path.home() / ".claude" / "plugins"
248+
return Path(get_claude_config_dir()) / "plugins"
248249

249250

250251
def _validate_marketplace_name(name: str) -> str:

tests/test_claude_mcp_manager.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import asyncio
66
import json
7+
import os
78
from pathlib import Path
89
from unittest.mock import MagicMock, patch
910

@@ -28,6 +29,9 @@ def claude_home(tmp_path, monkeypatch):
2829
home = tmp_path / "home"
2930
home.mkdir()
3031
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
32+
# A developer's exported CLAUDE_CONFIG_DIR would otherwise bypass the
33+
# Path.home seam now that the manager honors the override.
34+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
3135
return home
3236

3337

@@ -832,12 +836,20 @@ def setUp(self):
832836
Path, "home", classmethod(lambda cls: self._home)
833837
)
834838
self._home_patcher.start()
839+
# unittest-style classes don't get the claude_home fixture's delenv;
840+
# without this, an exported CLAUDE_CONFIG_DIR bypasses the Path.home
841+
# seam and the PATCH writes into the developer's real relocated
842+
# .claude.json.
843+
self._env_patcher = patch.dict(os.environ)
844+
self._env_patcher.start()
845+
os.environ.pop("CLAUDE_CONFIG_DIR", None)
835846
self._cwd_patcher = patch.object(
836847
ext_module, "get_jupyter_root_dir", lambda: str(self._cwd)
837848
)
838849
self._cwd_patcher.start()
839850

840851
def tearDown(self):
852+
self._env_patcher.stop()
841853
self._home_patcher.stop()
842854
self._cwd_patcher.stop()
843855
import shutil
@@ -896,3 +908,56 @@ def test_patch_rejects_missing_field(self):
896908
response = self._fetch_patch("/claude-mcp/user/voicemode", {})
897909
assert response.code == 400
898910
assert b"Missing" in response.body
911+
912+
913+
class TestClaudeUserConfigPath:
914+
"""`.claude.json` must follow CLAUDE_CONFIG_DIR (issue #375).
915+
916+
The CLI keeps the file at $HOME/.claude.json by default but relocates
917+
it to $CLAUDE_CONFIG_DIR/.claude.json when the override is set, so a
918+
manager reading the home path on an override deployment sees stale or
919+
missing user-scope servers.
920+
"""
921+
922+
def test_reads_relocated_config_when_override_set(
923+
self, tmp_path, working_dir, monkeypatch
924+
):
925+
override = tmp_path / "workspace" / ".claude"
926+
override.mkdir(parents=True)
927+
(override / ".claude.json").write_text(
928+
json.dumps(
929+
{
930+
"mcpServers": {
931+
"relocated": {
932+
"type": "http",
933+
"url": "https://example.com/mcp",
934+
}
935+
}
936+
}
937+
),
938+
encoding="utf-8",
939+
)
940+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(override))
941+
942+
manager = ClaudeMCPManager(working_dir=str(working_dir))
943+
944+
assert [s.name for s in manager.list_servers()] == ["relocated"]
945+
946+
def test_override_wins_over_home_config(
947+
self, claude_home, working_dir, monkeypatch, tmp_path
948+
):
949+
_write_claude_json(
950+
claude_home,
951+
{"mcpServers": {"home-srv": {"type": "http", "url": "https://h"}}},
952+
)
953+
override = tmp_path / "workspace" / ".claude"
954+
override.mkdir(parents=True)
955+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(override))
956+
957+
manager = ClaudeMCPManager(working_dir=str(working_dir))
958+
959+
assert manager.list_servers() == []
960+
961+
def test_defaults_to_home_when_unset(self, claude_home, working_dir):
962+
manager = ClaudeMCPManager(working_dir=str(working_dir))
963+
assert manager._user_config_path == claude_home / ".claude.json"

tests/test_plugin_manager.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from notebook_intelligence._claude_cli import redact_argv_for_log
1616
from notebook_intelligence.plugin_manager import (
1717
PluginManager,
18+
_claude_plugins_root,
1819
is_acceptable_marketplace_source,
1920
is_github_marketplace_source,
2021
)
@@ -1241,3 +1242,29 @@ def test_passes_through_non_secret_flags(self):
12411242
# Per-family policy-gate coverage lives in `tests/test_policy_gate.py`,
12421243
# parametrized across SkillsBaseHandler / ClaudeMCPBaseHandler /
12431244
# PluginsBaseHandler.
1245+
1246+
1247+
class TestClaudePluginsRoot:
1248+
"""The plugins-root fallback must follow CLAUDE_CONFIG_DIR (issue #375).
1249+
1250+
The CLI keeps its plugin cache under the config dir, so when
1251+
CLAUDE_CONFIG_DIR is set (and no explicit cache-dir override exists)
1252+
the root is $CLAUDE_CONFIG_DIR/plugins, not ~/.claude/plugins.
1253+
"""
1254+
1255+
def test_falls_back_to_claude_config_dir(self, tmp_path, monkeypatch):
1256+
monkeypatch.delenv("CLAUDE_CODE_PLUGIN_CACHE_DIR", raising=False)
1257+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cfg"))
1258+
assert _claude_plugins_root() == tmp_path / "cfg" / "plugins"
1259+
1260+
def test_explicit_cache_dir_wins_over_config_dir(self, tmp_path, monkeypatch):
1261+
monkeypatch.setenv("CLAUDE_CODE_PLUGIN_CACHE_DIR", str(tmp_path / "cache"))
1262+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cfg"))
1263+
assert _claude_plugins_root() == tmp_path / "cache"
1264+
1265+
def test_defaults_to_home_claude_plugins(self, tmp_path, monkeypatch):
1266+
monkeypatch.delenv("CLAUDE_CODE_PLUGIN_CACHE_DIR", raising=False)
1267+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
1268+
monkeypatch.setenv("HOME", str(tmp_path))
1269+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
1270+
assert _claude_plugins_root() == tmp_path / ".claude" / "plugins"

ui-tests/playwright.config.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
// its own; import the config helper from `@playwright/test` directly.
99
import { defineConfig, devices } from '@playwright/test';
1010

11+
const webServerEnv: { [key: string]: string } = Object.fromEntries(
12+
Object.entries(process.env).filter(
13+
([key, value]) => key !== 'CLAUDE_CONFIG_DIR' && value !== undefined
14+
)
15+
) as { [key: string]: string };
16+
1117
export default defineConfig({
1218
// Tests live next to this config so they can ``import { test, expect } from
1319
// '@jupyterlab/galata'`` without long relative paths.
@@ -23,7 +29,14 @@ export default defineConfig({
2329
command: 'jlpm start',
2430
url: 'http://localhost:8888/lab',
2531
timeout: 120_000,
26-
reuseExistingServer: !process.env.CI
32+
reuseExistingServer: !process.env.CI,
33+
// The MCP PATCH spec relies on HOME isolation to keep writes out of the
34+
// developer's real ~/.claude.json; an exported CLAUDE_CONFIG_DIR would
35+
// bypass that seam (the server follows the override), so drop it. Delete
36+
// rather than set to '': the claude CLI treats an empty string as a set
37+
// (and degenerate) config dir, so '' would only cover the server's own
38+
// file reads, not any future spec that shells out to the CLI.
39+
env: webServerEnv
2740
},
2841

2942
use: {

ui-tests/tests/claude-mcp-patch.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
* Requires HOME to point at an isolated directory the test owns; otherwise
1313
* the PATCH would mutate the user's real ~/.claude.json. The runner sets
1414
* HOME=/tmp/... before invoking jlpm playwright, and the seed file is
15-
* placed by the test itself before each run.
15+
* placed by the test itself before each run. CLAUDE_CONFIG_DIR must not
16+
* leak into the server env (the server follows the override, bypassing
17+
* the HOME seam); playwright.config.ts drops it from the webServer env.
1618
*/
1719
import { expect, test } from '@jupyterlab/galata';
1820

0 commit comments

Comments
 (0)