Merge pull request 'feat: discover Claude Code project .mcp.json configs as profiles (#53)' (#57) from feat/53-project-mcp-discovery into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 8s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 8s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
This commit was merged in pull request #57.
This commit is contained in:
+44
-1
@@ -282,10 +282,43 @@ def msix_warning_text(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profile]:
|
||||||
|
"""
|
||||||
|
Find project-scope `.mcp.json` configs known to Claude Code.
|
||||||
|
|
||||||
|
`~/.claude.json` keeps a `projects` map keyed by absolute project
|
||||||
|
directory path (that's what the CLI writes as it's used in each repo).
|
||||||
|
Any project whose directory has a `.mcp.json` file next to it -- a
|
||||||
|
standalone file with a top-level `mcpServers` object, same shape BCC
|
||||||
|
already edits -- is surfaced here as its own profile so it can be opened
|
||||||
|
via 'Add config...' without hunting for the path by hand.
|
||||||
|
|
||||||
|
Fails quiet: a missing/unreadable/malformed `claude_json_path`, or a
|
||||||
|
`projects` value that isn't a dict, just yields an empty list rather than
|
||||||
|
raising -- this is best-effort discovery, not a required config load.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cfg = load_config(claude_json_path)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
projects = cfg.get("projects")
|
||||||
|
if not isinstance(projects, dict):
|
||||||
|
return []
|
||||||
|
out: list[Profile] = []
|
||||||
|
for key in sorted(k for k in projects if isinstance(k, str)):
|
||||||
|
mcp_path = Path(key) / ".mcp.json"
|
||||||
|
if mcp_path.is_file():
|
||||||
|
out.append(
|
||||||
|
Profile(label=f"Project: {Path(key).name}", path=mcp_path, config_exists=True)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def discover_profiles() -> list[Profile]:
|
def discover_profiles() -> list[Profile]:
|
||||||
"""
|
"""
|
||||||
Find every `Claude*` data directory in the platform's app-support base
|
Find every `Claude*` data directory in the platform's app-support base
|
||||||
(Claude Desktop installs), then also check for a Claude Code global config.
|
(Claude Desktop installs), then also check for a Claude Code global config
|
||||||
|
and any project-scope `.mcp.json` configs it knows about.
|
||||||
|
|
||||||
Claude Desktop: scans the platform app-support folder for any `Claude*`
|
Claude Desktop: scans the platform app-support folder for any `Claude*`
|
||||||
directory (catches `Claude`, `Claude-Work`, etc.).
|
directory (catches `Claude`, `Claude-Work`, etc.).
|
||||||
@@ -298,6 +331,10 @@ def discover_profiles() -> list[Profile]:
|
|||||||
`claude mcp add` writes; project scope is a per-repo .mcp.json, which can
|
`claude mcp add` writes; project scope is a per-repo .mcp.json, which can
|
||||||
be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is
|
be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is
|
||||||
for permissions/hooks and rejects an mcpServers key with a schema error.
|
for permissions/hooks and rejects an mcpServers key with a schema error.
|
||||||
|
Project scope: ~/.claude.json also tracks a `projects` map, one entry per
|
||||||
|
directory Claude Code has been run in; any of those with a `.mcp.json`
|
||||||
|
file are surfaced as their own profiles too (see
|
||||||
|
`discover_project_configs`).
|
||||||
"""
|
"""
|
||||||
base = app_support_base()
|
base = app_support_base()
|
||||||
out: list[Profile] = []
|
out: list[Profile] = []
|
||||||
@@ -319,6 +356,12 @@ def discover_profiles() -> list[Profile]:
|
|||||||
cc_cfg = home / ".claude.json"
|
cc_cfg = home / ".claude.json"
|
||||||
out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file()))
|
out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file()))
|
||||||
|
|
||||||
|
existing_paths = {str(p.path) for p in out}
|
||||||
|
for proj in discover_project_configs(cc_cfg):
|
||||||
|
if str(proj.path) not in existing_paths:
|
||||||
|
existing_paths.add(str(proj.path))
|
||||||
|
out.append(proj)
|
||||||
|
|
||||||
# Legacy: earlier BCC versions (and hand-edits) may have parked servers in
|
# Legacy: earlier BCC versions (and hand-edits) may have parked servers in
|
||||||
# ~/.claude/settings.json, where Claude Code ignores them. Surface that
|
# ~/.claude/settings.json, where Claude Code ignores them. Surface that
|
||||||
# file only when it actually contains an mcpServers block, so the user can
|
# file only when it actually contains an mcpServers block, so the user can
|
||||||
|
|||||||
@@ -67,6 +67,64 @@ def test_legacy_settings_json_surfaced_only_with_servers(fake_home):
|
|||||||
assert any("legacy" in p.label for p in c.discover_profiles())
|
assert any("legacy" in p.label for p in c.discover_profiles())
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_with_mcp_json_is_discovered(tmp_path):
|
||||||
|
proj = tmp_path / "my-project"
|
||||||
|
proj.mkdir()
|
||||||
|
(proj / ".mcp.json").write_text('{"mcpServers": {"x": {"command": "npx"}}}')
|
||||||
|
claude_json = tmp_path / ".claude.json"
|
||||||
|
claude_json.write_text(json.dumps({"projects": {str(proj): {}}}))
|
||||||
|
|
||||||
|
profs = c.discover_project_configs(claude_json)
|
||||||
|
assert len(profs) == 1
|
||||||
|
assert profs[0].label == f"Project: {proj.name}"
|
||||||
|
assert profs[0].path == proj / ".mcp.json"
|
||||||
|
assert profs[0].config_exists
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_without_mcp_json_not_listed(tmp_path):
|
||||||
|
proj = tmp_path / "no-mcp-project"
|
||||||
|
proj.mkdir()
|
||||||
|
claude_json = tmp_path / ".claude.json"
|
||||||
|
claude_json.write_text(json.dumps({"projects": {str(proj): {}}}))
|
||||||
|
|
||||||
|
assert c.discover_project_configs(claude_json) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_missing_or_not_dict_yields_no_profiles(tmp_path):
|
||||||
|
claude_json = tmp_path / ".claude.json"
|
||||||
|
|
||||||
|
claude_json.write_text(json.dumps({}))
|
||||||
|
assert c.discover_project_configs(claude_json) == []
|
||||||
|
|
||||||
|
claude_json.write_text(json.dumps({"projects": ["not", "a", "dict"]}))
|
||||||
|
assert c.discover_project_configs(claude_json) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_claude_json_fails_quiet(fake_home):
|
||||||
|
(fake_home / ".claude.json").write_text("{not valid json")
|
||||||
|
assert c.discover_project_configs(fake_home / ".claude.json") == []
|
||||||
|
# discover_profiles as a whole must still work and just skip project profiles
|
||||||
|
profs = c.discover_profiles()
|
||||||
|
assert not any(p.label.startswith("Project:") for p in profs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_configs_deduped_against_existing_profiles(fake_home):
|
||||||
|
# Point a project directly at the Claude Code profile's own .mcp.json-shaped
|
||||||
|
# path to prove discover_profiles() won't duplicate an already-listed path.
|
||||||
|
proj = fake_home / "dup-project"
|
||||||
|
proj.mkdir()
|
||||||
|
mcp_path = proj / ".mcp.json"
|
||||||
|
mcp_path.write_text('{"mcpServers": {"x": {"command": "npx"}}}')
|
||||||
|
(fake_home / ".claude.json").write_text(json.dumps({"projects": {str(proj): {}}}))
|
||||||
|
|
||||||
|
direct = c.discover_project_configs(fake_home / ".claude.json")
|
||||||
|
assert len(direct) == 1
|
||||||
|
|
||||||
|
profs = c.discover_profiles()
|
||||||
|
project_profiles = [p for p in profs if str(p.path) == str(mcp_path)]
|
||||||
|
assert len(project_profiles) == 1
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# 2. Write pipeline: preserves other keys + order, only touches mcpServers
|
# 2. Write pipeline: preserves other keys + order, only touches mcpServers
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
Reference in New Issue
Block a user