Compare commits
4 Commits
v1.2.1
...
6b22ad26f0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b22ad26f0 | |||
| 874948506c | |||
| 82ff149373 | |||
| 520b1b2ffd |
@@ -2309,6 +2309,11 @@ class MainWindow(QMainWindow):
|
||||
self.validation_lbl.setStyleSheet(f"color: {WARN};")
|
||||
self.save_btn.setEnabled(False)
|
||||
return False
|
||||
lint_warnings = core.lint_servers(self.servers)
|
||||
if lint_warnings:
|
||||
self.validation_lbl.setText(f"⚠ {lint_warnings[0]}")
|
||||
self.validation_lbl.setStyleSheet(f"color: {WARN};")
|
||||
else:
|
||||
self.validation_lbl.setText("✓ valid")
|
||||
self.validation_lbl.setStyleSheet(f"color: {GOOD};")
|
||||
self.save_btn.setEnabled(self.dirty)
|
||||
|
||||
+101
-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]:
|
||||
"""
|
||||
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
|
||||
@@ -1213,6 +1256,63 @@ def validate_servers(servers: list[ServerEntry]) -> list[str]:
|
||||
return problems
|
||||
|
||||
|
||||
def lint_server(name: str, data: dict) -> list[str]:
|
||||
"""Return non-blocking structural warnings for a single server definition.
|
||||
|
||||
Unlike validate_servers, nothing here blocks Save -- these are advisory
|
||||
notes about shapes that will round-trip through JSON fine but are
|
||||
probably not what the user intended (args given as a plain string
|
||||
instead of a list, an env value that isn't a string, an unrecognized
|
||||
`type`, unknown top-level fields, etc.).
|
||||
"""
|
||||
nm = name.strip() or "(unnamed)"
|
||||
warnings: list[str] = []
|
||||
|
||||
if "command" in data and not isinstance(data["command"], str):
|
||||
warnings.append(f"'{nm}': 'command' should be a string")
|
||||
|
||||
if "args" in data:
|
||||
args = data["args"]
|
||||
if not isinstance(args, list):
|
||||
warnings.append(f"'{nm}': 'args' should be a list (one argument per item)")
|
||||
elif any(not isinstance(a, str) for a in args):
|
||||
warnings.append(
|
||||
f"'{nm}': 'args' contains non-string values "
|
||||
"(they will be saved as-is; Claude expects strings)"
|
||||
)
|
||||
|
||||
for field in ("env", "headers"):
|
||||
if field not in data:
|
||||
continue
|
||||
val = data[field]
|
||||
if not isinstance(val, dict):
|
||||
warnings.append(f"'{nm}': '{field}' should be an object of string key/value pairs")
|
||||
elif any(not isinstance(v, str) for v in val.values()):
|
||||
warnings.append(
|
||||
f"'{nm}': '{field}' contains non-string values "
|
||||
"(they will be saved as-is; Claude expects strings)"
|
||||
)
|
||||
|
||||
if "type" in data:
|
||||
t = data["type"]
|
||||
if t not in ("http", "sse", "stdio"):
|
||||
warnings.append(f"'{nm}': 'type' should be one of http, sse, stdio (found {t!r})")
|
||||
|
||||
extra = sorted(k for k in data if k not in KNOWN_FIELDS)
|
||||
if extra:
|
||||
warnings.append(f"'{nm}': extra fields preserved as-is: {', '.join(extra)}")
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def lint_servers(servers: list[ServerEntry]) -> list[str]:
|
||||
"""Concatenate lint_server warnings across every entry, in order."""
|
||||
out: list[str] = []
|
||||
for s in servers:
|
||||
out.extend(lint_server(s.name, s.data))
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Search / filter
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -181,6 +239,85 @@ def test_valid_set_passes_clean():
|
||||
assert c.validate_servers([c.ServerEntry("good", {"command": "node"}, True)]) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4b. Lint: non-blocking structural warnings
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_lint_flags_non_string_command():
|
||||
warnings = c.lint_server("x", {"command": 5})
|
||||
assert any("'command' should be a string" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_args_not_a_list():
|
||||
warnings = c.lint_server("x", {"command": "node", "args": "-y foo"})
|
||||
assert any("'args' should be a list" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_args_with_non_string_items():
|
||||
warnings = c.lint_server("x", {"command": "node", "args": ["-y", 5]})
|
||||
assert any("'args' contains non-string values" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_env_not_a_dict():
|
||||
warnings = c.lint_server("x", {"command": "node", "env": ["FOO=bar"]})
|
||||
assert any("'env' should be an object" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_env_with_non_string_values():
|
||||
warnings = c.lint_server("x", {"command": "node", "env": {"FOO": 5}})
|
||||
assert any("'env' contains non-string values" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_headers_not_a_dict():
|
||||
warnings = c.lint_server("x", {"url": "https://x", "headers": ["Authorization: x"]})
|
||||
assert any("'headers' should be an object" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_headers_with_non_string_values():
|
||||
warnings = c.lint_server("x", {"url": "https://x", "headers": {"Authorization": 5}})
|
||||
assert any("'headers' contains non-string values" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_bad_type_value():
|
||||
warnings = c.lint_server("x", {"url": "https://x", "type": "websocket"})
|
||||
assert any("'type'" in w and "websocket" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_extra_unknown_fields():
|
||||
warnings = c.lint_server("x", {"command": "node", "cwd": "/tmp", "timeout": 30})
|
||||
matches = [w for w in warnings if "extra fields preserved as-is" in w]
|
||||
assert len(matches) == 1
|
||||
assert "cwd" in matches[0]
|
||||
assert "timeout" in matches[0]
|
||||
|
||||
|
||||
def test_lint_clean_server_yields_no_warnings():
|
||||
assert c.lint_server("good", {"command": "node", "args": ["a.js"]}) == []
|
||||
assert (
|
||||
c.lint_server(
|
||||
"remote", {"url": "https://x", "type": "http", "headers": {"Authorization": "x"}}
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_lint_servers_aggregates_across_entries():
|
||||
entries = [
|
||||
c.ServerEntry("a", {"command": 5}, True),
|
||||
c.ServerEntry("b", {"url": "https://x", "type": "bogus"}, True),
|
||||
]
|
||||
warnings = c.lint_servers(entries)
|
||||
assert any("'a'" in w and "'command' should be a string" in w for w in warnings)
|
||||
assert any("'b'" in w and "'type'" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_warning_is_not_a_blocking_problem():
|
||||
# args given as a single string isn't caught by validate_servers (a
|
||||
# command is still present), but it's a lint warning.
|
||||
entry = c.ServerEntry("x", {"command": "node", "args": "-y foo"}, True)
|
||||
assert c.validate_servers([entry]) == []
|
||||
assert c.lint_servers([entry]) != []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5. Secrets: detection + redaction
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user