diff --git a/bcc_core.py b/bcc_core.py index be511cf..f6fb203 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -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]: """ 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*` 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 be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is 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() out: list[Profile] = [] @@ -319,6 +356,12 @@ def discover_profiles() -> list[Profile]: cc_cfg = home / ".claude.json" 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 # ~/.claude/settings.json, where Claude Code ignores them. Surface that # file only when it actually contains an mcpServers block, so the user can diff --git a/tests/test_core.py b/tests/test_core.py index 67f17fe..ccd4d93 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -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()) +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 # --------------------------------------------------------------------------- #