Initial commit: Better Claude Config
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# PyInstaller / build
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# App runtime
|
||||||
|
.bcc_backups/
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 AJ
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Better Claude Config (BCC)
|
||||||
|
|
||||||
|
A small, cross-platform GUI for editing the `mcpServers` block of one or more
|
||||||
|
Claude Desktop installs — without ever hand-writing JSON.
|
||||||
|
|
||||||
|
BCC only ever touches `mcpServers` (and its own `_disabledMcpServers` parking
|
||||||
|
key). Every other key in your config is preserved verbatim, in its original
|
||||||
|
order. Each write is atomic and makes a timestamped backup first.
|
||||||
|
|
||||||
|
## Install & run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python bcc.py
|
||||||
|
```
|
||||||
|
|
||||||
|
(Python 3.10+.)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Auto-discovers installs** — scans the platform's app-support folder for any
|
||||||
|
`Claude*` directory (so `Claude` and `Claude-Work` both show up), and lets you
|
||||||
|
**Add config…** to point at any file manually.
|
||||||
|
- **Form-based editing** — name, command, args (one per line), env vars, or for
|
||||||
|
remote servers: URL, transport, and headers. No raw JSON.
|
||||||
|
- **Paste JSON** — drop in any snippet from an MCP doc (full `mcpServers` block,
|
||||||
|
inner map, or a single bare server object); it's parsed and merged.
|
||||||
|
- **Drag & drop** a `.json` file onto the window to import servers from it.
|
||||||
|
- **Copy to ▸** — copy the selected server straight into your *other* install.
|
||||||
|
- **Active / Disabled sections** — servers are shown in two labelled lists with
|
||||||
|
live counts, so a server that's switched off in the config is obvious at a
|
||||||
|
glance. Toggle a server's checkbox to move it between sections (disabled
|
||||||
|
servers are parked under `_disabledMcpServers`, which Claude ignores).
|
||||||
|
- **Dependency check + diagnostics** — each server shows whether its `command`
|
||||||
|
is actually found on PATH:
|
||||||
|
- ● green — found on the normal PATH; will work anywhere.
|
||||||
|
- ▲ amber (**PATH-risk**) — found, but *only* in a location BCC added on top of
|
||||||
|
the inherited PATH. It'll work when you launch Claude from a terminal, but a
|
||||||
|
double-clicked `.app` may not see it. This is the usual cause of "the tool
|
||||||
|
says it's fine but Claude won't start the server." One click on **Use full
|
||||||
|
path ↳** rewrites the bare command (`npx`) to its absolute path so any launch
|
||||||
|
finds it.
|
||||||
|
- ● red (**broken**) — not found anywhere.
|
||||||
|
- ◆ blue — remote (url) server. **Test connection** does a live reachability
|
||||||
|
check on a background thread (any HTTP response = reachable).
|
||||||
|
|
||||||
|
Each section header shows a **broken / PATH-risk count badge**, and selecting a
|
||||||
|
flagged server auto-opens a **Details** panel with a full, copy-pasteable
|
||||||
|
report: which command resolved to what, tailored install hints, and the exact
|
||||||
|
PATH that was searched (with `+` marking the dirs BCC added). **Re-check**
|
||||||
|
re-scans PATH after you install something; **Copy diagnostics** grabs the whole
|
||||||
|
report for a bug report.
|
||||||
|
|
||||||
|
After saving, **restart that Claude install** for changes to take effect.
|
||||||
|
|
||||||
|
## Config locations it scans
|
||||||
|
|
||||||
|
| OS | Base it scans for `Claude*` |
|
||||||
|
|---------|---------------------------------|
|
||||||
|
| macOS | `~/Library/Application Support` |
|
||||||
|
| Windows | `%APPDATA%` |
|
||||||
|
| Linux | `~/.config` |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `bcc.py` — the GUI.
|
||||||
|
- `bcc_core.py` — all the file/JSON/validation/dependency logic (no GUI deps).
|
||||||
|
- `test_core.py` — unit suite for the core (`python test_core.py`).
|
||||||
|
|
||||||
|
## Rebrand
|
||||||
|
|
||||||
|
The accent color is one constant (`ACCENT`) at the top of `bcc.py`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
+540
@@ -0,0 +1,540 @@
|
|||||||
|
"""
|
||||||
|
mcp_core.py -- Pure logic for the Claude MCP config manager.
|
||||||
|
|
||||||
|
No GUI imports live in here on purpose: every function below is unit-testable
|
||||||
|
and could just as easily back a CLI. The GUI (mcp_manager.py) is a thin shell
|
||||||
|
over these functions.
|
||||||
|
|
||||||
|
The cardinal rule of this module: when writing a config back to disk we ONLY
|
||||||
|
ever touch the `mcpServers` block (and our own `_disabledMcpServers` parking
|
||||||
|
key). Every other top-level key in the user's config is preserved verbatim and
|
||||||
|
in its original position.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
CONFIG_FILENAME = "claude_desktop_config.json"
|
||||||
|
|
||||||
|
# Disabled servers are parked under this non-standard key. Claude Desktop only
|
||||||
|
# reads `mcpServers`, so anything here is ignored by the app but kept on disk so
|
||||||
|
# we can toggle it back on without losing the definition.
|
||||||
|
DISABLED_KEY = "_disabledMcpServers"
|
||||||
|
|
||||||
|
BACKUP_DIRNAME = ".bcc_backups"
|
||||||
|
MAX_BACKUPS = 15
|
||||||
|
|
||||||
|
# Fields the editor knows how to render. Anything else on a server object is
|
||||||
|
# considered "extra" and is preserved untouched on save.
|
||||||
|
KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Data model
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@dataclass
|
||||||
|
class Profile:
|
||||||
|
"""A discovered (or manually added) Claude install location."""
|
||||||
|
label: str
|
||||||
|
path: Path
|
||||||
|
config_exists: bool
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.path = Path(self.path)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServerEntry:
|
||||||
|
name: str
|
||||||
|
data: dict
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def kind(self) -> str:
|
||||||
|
return "remote" if "url" in self.data and "command" not in self.data else "stdio"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Discovery
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def app_support_base() -> Path:
|
||||||
|
"""The per-platform directory that holds the `Claude*` data folders."""
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return Path.home() / "Library" / "Application Support"
|
||||||
|
if os.name == "nt":
|
||||||
|
return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
||||||
|
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||||
|
|
||||||
|
|
||||||
|
def discover_profiles() -> list[Profile]:
|
||||||
|
"""
|
||||||
|
Find every `Claude*` data directory in the platform's app-support base.
|
||||||
|
|
||||||
|
This catches the standard `Claude` folder plus any sibling like
|
||||||
|
`Claude-Work`, `Claude-Personal`, etc. A directory is listed even if the
|
||||||
|
config file doesn't exist yet (it will be created on first save).
|
||||||
|
"""
|
||||||
|
base = app_support_base()
|
||||||
|
out: list[Profile] = []
|
||||||
|
if base.is_dir():
|
||||||
|
seen = set()
|
||||||
|
for d in sorted(base.glob("Claude*")):
|
||||||
|
if d.is_dir() and d.name not in seen:
|
||||||
|
seen.add(d.name)
|
||||||
|
cfg = d / CONFIG_FILENAME
|
||||||
|
out.append(Profile(label=d.name, path=cfg, config_exists=cfg.is_file()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def profile_from_path(path: str | os.PathLike) -> Profile:
|
||||||
|
"""Build a Profile from a user-supplied config path (the 'override' case)."""
|
||||||
|
p = Path(path)
|
||||||
|
# Prefer the parent folder name as the label (e.g. .../Claude-Work/...json -> Claude-Work)
|
||||||
|
label = p.parent.name or p.name
|
||||||
|
return Profile(label=label, path=p, config_exists=p.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Load / extract / apply
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def load_config(path: str | os.PathLike) -> dict:
|
||||||
|
"""Read the full config dict. Missing/empty file -> {}. Bad JSON -> raises."""
|
||||||
|
p = Path(path)
|
||||||
|
if not p.is_file():
|
||||||
|
return {}
|
||||||
|
text = p.read_text(encoding="utf-8")
|
||||||
|
if not text.strip():
|
||||||
|
return {}
|
||||||
|
obj = json.loads(text) # JSONDecodeError bubbles up for the GUI to display
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise ValueError("Top-level JSON in the config file is not an object.")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def extract_servers(cfg: dict) -> list[ServerEntry]:
|
||||||
|
"""Pull enabled (`mcpServers`) and disabled (`_disabledMcpServers`) servers."""
|
||||||
|
out: list[ServerEntry] = []
|
||||||
|
for name, data in (cfg.get("mcpServers") or {}).items():
|
||||||
|
out.append(ServerEntry(name=name, data=dict(data), enabled=True))
|
||||||
|
for name, data in (cfg.get(DISABLED_KEY) or {}).items():
|
||||||
|
out.append(ServerEntry(name=name, data=dict(data), enabled=False))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def apply_servers(cfg: dict, servers: list[ServerEntry]) -> dict:
|
||||||
|
"""
|
||||||
|
Write the server list back into `cfg` in place, preserving every other key
|
||||||
|
and the position of `mcpServers`. Returns the same dict for convenience.
|
||||||
|
"""
|
||||||
|
enabled = {s.name: s.data for s in servers if s.enabled}
|
||||||
|
disabled = {s.name: s.data for s in servers if not s.enabled}
|
||||||
|
|
||||||
|
cfg["mcpServers"] = enabled # replaces value if key existed; appends otherwise
|
||||||
|
if disabled:
|
||||||
|
cfg[DISABLED_KEY] = disabled
|
||||||
|
else:
|
||||||
|
cfg.pop(DISABLED_KEY, None)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Write (atomic, with rotating backups)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _make_backup(path: Path) -> Path:
|
||||||
|
bdir = path.parent / BACKUP_DIRNAME
|
||||||
|
bdir.mkdir(exist_ok=True)
|
||||||
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||||
|
dest = bdir / f"{path.stem}.{stamp}.json"
|
||||||
|
# Avoid clobbering a same-second backup
|
||||||
|
n = 1
|
||||||
|
while dest.exists():
|
||||||
|
dest = bdir / f"{path.stem}.{stamp}.{n}.json"
|
||||||
|
n += 1
|
||||||
|
shutil.copy2(path, dest)
|
||||||
|
# Prune oldest beyond MAX_BACKUPS
|
||||||
|
backups = sorted(bdir.glob(f"{path.stem}.*.json"))
|
||||||
|
for old in backups[:-MAX_BACKUPS]:
|
||||||
|
try:
|
||||||
|
old.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def write_config(path: str | os.PathLike, cfg: dict) -> Path | None:
|
||||||
|
"""
|
||||||
|
Atomically write `cfg` to `path` (2-space pretty JSON). Backs up any existing
|
||||||
|
file first. Returns the backup path (or None if there was nothing to back up).
|
||||||
|
"""
|
||||||
|
p = Path(path)
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
backup = _make_backup(p) if p.is_file() else None
|
||||||
|
|
||||||
|
payload = json.dumps(cfg, indent=2, ensure_ascii=False) + "\n"
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=str(p.parent), prefix=".tmp_mcp_", suffix=".json")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
f.write(payload)
|
||||||
|
os.replace(tmp, p) # atomic on the same filesystem
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.remove(tmp)
|
||||||
|
return backup
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Paste / import parsing
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def looks_like_server(data) -> bool:
|
||||||
|
return isinstance(data, dict) and ("command" in data or "url" in data)
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_name(data: dict) -> str:
|
||||||
|
"""Derive a friendly default name from a server definition."""
|
||||||
|
if "url" in data and "command" not in data:
|
||||||
|
host = (urlparse(data["url"]).hostname or "remote")
|
||||||
|
return host.split(".")[0] or "remote"
|
||||||
|
for a in data.get("args", []) or []:
|
||||||
|
if isinstance(a, str) and ("/" in a or a.startswith("@")):
|
||||||
|
base = a.rstrip("/").split("/")[-1]
|
||||||
|
base = base.replace("server-", "").replace("mcp-server-", "").replace("mcp-", "")
|
||||||
|
if base:
|
||||||
|
return base
|
||||||
|
cmd = data.get("command", "server")
|
||||||
|
return Path(str(cmd)).stem or "server"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pasted_json(text: str) -> dict[str, dict]:
|
||||||
|
"""
|
||||||
|
Accept any of the shapes MCP docs hand out and return {name: server_dict}:
|
||||||
|
|
||||||
|
1. Full config: {"mcpServers": {"name": {...}}}
|
||||||
|
2. Inner block only: {"name": {"command": ...}}
|
||||||
|
3. A bare server obj: {"command": ..., "args": [...]} (name is suggested)
|
||||||
|
|
||||||
|
Raises ValueError with a human message on anything unrecognizable.
|
||||||
|
"""
|
||||||
|
text = (text or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Nothing to parse.")
|
||||||
|
obj = json.loads(text)
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise ValueError("Top-level JSON must be an object ({ ... }).")
|
||||||
|
|
||||||
|
if isinstance(obj.get("mcpServers"), dict):
|
||||||
|
servers = obj["mcpServers"]
|
||||||
|
elif looks_like_server(obj):
|
||||||
|
servers = {suggest_name(obj): obj}
|
||||||
|
elif obj and all(isinstance(v, dict) for v in obj.values()):
|
||||||
|
servers = obj
|
||||||
|
else:
|
||||||
|
raise ValueError("Couldn't find any MCP server definitions in that JSON.")
|
||||||
|
|
||||||
|
clean: dict[str, dict] = {}
|
||||||
|
for name, data in servers.items():
|
||||||
|
if not looks_like_server(data):
|
||||||
|
raise ValueError(
|
||||||
|
f"'{name}' doesn't look like an MCP server "
|
||||||
|
f"(it needs a 'command' or a 'url')."
|
||||||
|
)
|
||||||
|
clean[str(name)] = data
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Validation
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def validate_servers(servers: list[ServerEntry]) -> list[str]:
|
||||||
|
"""Return a list of human-readable problems. Empty list == all good."""
|
||||||
|
problems: list[str] = []
|
||||||
|
seen: dict[str, int] = {}
|
||||||
|
for s in servers:
|
||||||
|
nm = s.name.strip()
|
||||||
|
if not nm:
|
||||||
|
problems.append("A server has an empty name.")
|
||||||
|
seen[nm] = seen.get(nm, 0) + 1
|
||||||
|
if s.kind == "stdio":
|
||||||
|
if not str(s.data.get("command", "")).strip():
|
||||||
|
problems.append(f"'{nm or '(unnamed)'}' is a local server but has no command.")
|
||||||
|
else:
|
||||||
|
url = str(s.data.get("url", "")).strip()
|
||||||
|
if not url:
|
||||||
|
problems.append(f"'{nm or '(unnamed)'}' is a remote server but has no URL.")
|
||||||
|
elif not (url.startswith("http://") or url.startswith("https://")):
|
||||||
|
problems.append(f"'{nm}' has a URL that isn't http(s).")
|
||||||
|
for nm, count in seen.items():
|
||||||
|
if nm and count > 1:
|
||||||
|
problems.append(f"Duplicate server name: '{nm}' ({count}x).")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Dependency / PATH checking
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@functools.lru_cache(maxsize=1)
|
||||||
|
def augmented_path() -> str:
|
||||||
|
"""
|
||||||
|
PATH + the usual runtime install locations. GUI apps (especially bundled
|
||||||
|
.app launches) often don't inherit the shell PATH, so npx/uvx can appear
|
||||||
|
'missing' when they're actually fine. This widens the net.
|
||||||
|
|
||||||
|
Memoized for the session (PATH rarely changes mid-run). Call
|
||||||
|
refresh_path_cache() after the user installs something to re-scan.
|
||||||
|
"""
|
||||||
|
parts = os.environ.get("PATH", "").split(os.pathsep)
|
||||||
|
home = Path.home()
|
||||||
|
extra = [
|
||||||
|
"/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin",
|
||||||
|
str(home / ".local" / "bin"),
|
||||||
|
str(home / ".cargo" / "bin"),
|
||||||
|
str(home / ".deno" / "bin"),
|
||||||
|
str(home / ".bun" / "bin"),
|
||||||
|
str(home / ".volta" / "bin"),
|
||||||
|
]
|
||||||
|
extra += glob.glob(str(home / ".nvm" / "versions" / "node" / "*" / "bin"))
|
||||||
|
extra += glob.glob(str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin"))
|
||||||
|
if os.name == "nt":
|
||||||
|
appdata = os.environ.get("APPDATA", "")
|
||||||
|
if appdata:
|
||||||
|
extra.append(str(Path(appdata) / "npm"))
|
||||||
|
|
||||||
|
seen, ordered = set(), []
|
||||||
|
for p in parts + extra:
|
||||||
|
if p and p not in seen and Path(p).is_dir():
|
||||||
|
seen.add(p)
|
||||||
|
ordered.append(p)
|
||||||
|
return os.pathsep.join(ordered)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_path_cache():
|
||||||
|
"""Forget the memoized PATH so the next check re-scans (e.g. after an install)."""
|
||||||
|
augmented_path.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Tailored install advice keyed by the runner's basename.
|
||||||
|
RUNNER_HINTS = {
|
||||||
|
"npx": "Node.js / npx not found. Install Node from nodejs.org or `brew install node`. "
|
||||||
|
"With nvm, the binary lives at ~/.nvm/versions/node/<version>/bin.",
|
||||||
|
"node": "Node.js not found. Install from nodejs.org or `brew install node`.",
|
||||||
|
"uvx": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` "
|
||||||
|
"(or `brew install uv`). uvx ships inside uv.",
|
||||||
|
"uv": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` "
|
||||||
|
"(or `brew install uv`).",
|
||||||
|
"python": "Python not found on this PATH.",
|
||||||
|
"python3": "Python 3 not found on this PATH.",
|
||||||
|
"docker": "Docker not found. Install Docker Desktop and make sure it's running.",
|
||||||
|
"bun": "Bun not found. Install with `curl -fsSL https://bun.sh/install | bash`.",
|
||||||
|
"deno": "Deno not found. Install with `curl -fsSL https://deno.land/install.sh | sh`.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def runner_hint(cmd: str) -> str:
|
||||||
|
base = Path(cmd).name.lower()
|
||||||
|
if base.endswith(".exe"):
|
||||||
|
base = base[:-4]
|
||||||
|
if base in RUNNER_HINTS:
|
||||||
|
return RUNNER_HINTS[base]
|
||||||
|
if ("/" in cmd) or ("\\" in cmd):
|
||||||
|
return (f"'{cmd}' looks like a file path, but it doesn't exist or isn't executable. "
|
||||||
|
f"Check the path and that it's marked executable (chmod +x).")
|
||||||
|
return f"'{cmd}' was not found on PATH. Install it, or put the full path to the executable in 'command'."
|
||||||
|
|
||||||
|
|
||||||
|
SHELL_WRAPPERS = {"cmd", "cmd.exe", "sh", "bash", "zsh", "powershell", "powershell.exe", "pwsh"}
|
||||||
|
|
||||||
|
|
||||||
|
def _commands_to_check(data: dict) -> list[str]:
|
||||||
|
cmd = data.get("command")
|
||||||
|
if not cmd:
|
||||||
|
return []
|
||||||
|
cmds = [str(cmd)]
|
||||||
|
base = Path(str(cmd)).name.lower()
|
||||||
|
if base in SHELL_WRAPPERS:
|
||||||
|
for a in data.get("args", []) or []:
|
||||||
|
if isinstance(a, str) and not a.startswith(("/", "-")) and " " not in a:
|
||||||
|
cmds.append(a)
|
||||||
|
break
|
||||||
|
return cmds
|
||||||
|
|
||||||
|
|
||||||
|
def check_dependency(data: dict, path: str | None = None) -> dict:
|
||||||
|
"""
|
||||||
|
Decide whether a server's command can actually be found, and gather enough
|
||||||
|
context to troubleshoot when it can't.
|
||||||
|
|
||||||
|
status is one of:
|
||||||
|
'ok' - resolved on the normal (inherited) PATH; will work anywhere.
|
||||||
|
'warn' - resolved, but ONLY via an augmented location. Works in a
|
||||||
|
terminal launch; a bundled .app launch of Claude may not see it.
|
||||||
|
'missing' - not found anywhere.
|
||||||
|
'remote' - a url-based server (no local command to check).
|
||||||
|
'unknown' - no command set.
|
||||||
|
|
||||||
|
Returns: {status, label, resolved, in_base, searched_path, hints, commands, detail}
|
||||||
|
"""
|
||||||
|
if "url" in data and "command" not in data:
|
||||||
|
return {"status": "remote", "label": "remote endpoint", "detail": data.get("url", ""),
|
||||||
|
"resolved": {}, "in_base": {}, "searched_path": "", "hints": [], "commands": []}
|
||||||
|
|
||||||
|
cmds = _commands_to_check(data)
|
||||||
|
if not cmds:
|
||||||
|
return {"status": "unknown", "label": "no command set", "detail": "",
|
||||||
|
"resolved": {}, "in_base": {}, "searched_path": "",
|
||||||
|
"hints": ["This server has no 'command' to run."], "commands": []}
|
||||||
|
|
||||||
|
aug = path or augmented_path()
|
||||||
|
resolved: dict[str, str | None] = {}
|
||||||
|
in_base: dict[str, bool] = {}
|
||||||
|
for c in cmds:
|
||||||
|
if (os.sep in c) or ("/" in c): # explicit path, independent of PATH
|
||||||
|
cp = Path(c)
|
||||||
|
ok = cp.exists() and os.access(cp, os.X_OK)
|
||||||
|
resolved[c] = str(cp) if ok else None
|
||||||
|
in_base[c] = ok
|
||||||
|
else:
|
||||||
|
resolved[c] = shutil.which(c, path=aug)
|
||||||
|
in_base[c] = bool(shutil.which(c)) # raw inherited PATH only
|
||||||
|
|
||||||
|
missing = [c for c, r in resolved.items() if not r]
|
||||||
|
hints: list[str] = []
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
for c in missing:
|
||||||
|
hints.append(runner_hint(c))
|
||||||
|
status = "missing"
|
||||||
|
label = "missing: " + ", ".join(missing)
|
||||||
|
else:
|
||||||
|
nonbase = [c for c in resolved if not in_base[c]]
|
||||||
|
if nonbase:
|
||||||
|
status = "warn"
|
||||||
|
label = "found, but only on a non-standard PATH"
|
||||||
|
for c in nonbase:
|
||||||
|
hints.append(
|
||||||
|
f"'{c}' was found at {resolved[c]}, but only via a location this app added on "
|
||||||
|
f"top of the inherited PATH. If you launch Claude Desktop as an app (not from a "
|
||||||
|
f"terminal), it may not see this. Putting the full path in 'command' is the reliable fix."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status = "ok"
|
||||||
|
label = "found: " + ", ".join(Path(p).name for p in resolved.values())
|
||||||
|
|
||||||
|
return {"status": status, "label": label, "resolved": resolved, "in_base": in_base,
|
||||||
|
"searched_path": aug, "hints": hints, "commands": cmds, "detail": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def diagnostics_text(name: str, data: dict) -> str:
|
||||||
|
"""A copy-pasteable plain-text troubleshooting report for one server."""
|
||||||
|
res = check_dependency(data)
|
||||||
|
L: list[str] = []
|
||||||
|
if res["status"] == "remote":
|
||||||
|
L.append(f"Server: {name or '(unnamed)'} [remote]")
|
||||||
|
L.append(f"URL: {data.get('url', '')}")
|
||||||
|
if data.get("type"):
|
||||||
|
L.append(f"Transport: {data['type']}")
|
||||||
|
if data.get("headers"):
|
||||||
|
L.append("Headers: " + ", ".join(data["headers"].keys()))
|
||||||
|
L.append("Status: REMOTE (network reachability is not checked)")
|
||||||
|
return "\n".join(L)
|
||||||
|
|
||||||
|
L.append(f"Server: {name or '(unnamed)'} [local / stdio]")
|
||||||
|
L.append(f"Command: {data.get('command', '')}")
|
||||||
|
if data.get("args"):
|
||||||
|
L.append("Args: " + " ".join(str(a) for a in data["args"]))
|
||||||
|
if data.get("env"):
|
||||||
|
L.append("Env keys: " + ", ".join(data["env"].keys()))
|
||||||
|
L.append(f"Status: {res['status'].upper()}")
|
||||||
|
L.append("")
|
||||||
|
L.append("Command resolution:")
|
||||||
|
for c, r in res["resolved"].items():
|
||||||
|
if r:
|
||||||
|
tag = "" if res["in_base"].get(c) else " <-- non-standard PATH; app may not see it"
|
||||||
|
L.append(f" {c} -> {r}{tag}")
|
||||||
|
else:
|
||||||
|
L.append(f" {c} -> NOT FOUND")
|
||||||
|
if res["hints"]:
|
||||||
|
L.append("")
|
||||||
|
L.append("Hints:")
|
||||||
|
for h in res["hints"]:
|
||||||
|
L.append(f" - {h}")
|
||||||
|
|
||||||
|
base_dirs = [p for p in os.environ.get("PATH", "").split(os.pathsep) if p]
|
||||||
|
aug_dirs = res["searched_path"].split(os.pathsep) if res["searched_path"] else []
|
||||||
|
extra = [d for d in aug_dirs if d not in base_dirs]
|
||||||
|
L.append("")
|
||||||
|
L.append(f"PATH searched ({len(aug_dirs)} dirs):")
|
||||||
|
for d in aug_dirs:
|
||||||
|
L.append((" + " if d in extra else " ") + d)
|
||||||
|
if extra:
|
||||||
|
L.append("")
|
||||||
|
L.append("(+ = added by this app on top of the PATH it inherited. Claude Desktop, "
|
||||||
|
"launched separately, may NOT have these on its PATH.)")
|
||||||
|
return "\n".join(L)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Reachability check for a url-based MCP server. ANY HTTP response (even 4xx/5xx)
|
||||||
|
counts as reachable; only connection/DNS/timeout failures count as unreachable.
|
||||||
|
Returns (ok, detail). Network call — run this off the UI thread.
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
url = (url or "").strip()
|
||||||
|
if not (url.startswith("http://") or url.startswith("https://")):
|
||||||
|
return False, "not an http(s) URL"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, method="GET",
|
||||||
|
headers={"Accept": "text/event-stream, application/json, */*",
|
||||||
|
"User-Agent": "BetterClaudeConfig/1.0"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return True, f"HTTP {r.status}"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return True, f"HTTP {e.code} (reachable)"
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
reason = getattr(e, "reason", e)
|
||||||
|
if isinstance(reason, socket.timeout):
|
||||||
|
return False, "timed out"
|
||||||
|
return False, str(reason)
|
||||||
|
except socket.timeout:
|
||||||
|
return False, "timed out"
|
||||||
|
except Exception as e: # noqa: BLE001 - surface anything else as a clean message
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | None]:
|
||||||
|
"""
|
||||||
|
Fix a 'PATH-risk' (warn) server by rewriting the bare runner name to the
|
||||||
|
absolute path it resolved to, so any process (incl. a bundled Claude.app)
|
||||||
|
can find it. Returns (new_data, note). note is None if nothing was pinned.
|
||||||
|
"""
|
||||||
|
res = check_dependency(data, path)
|
||||||
|
if res["status"] != "warn":
|
||||||
|
return data, None
|
||||||
|
out = dict(data)
|
||||||
|
for c, resolved in res["resolved"].items():
|
||||||
|
if resolved and not res["in_base"].get(c, True) and ("/" not in c and "\\" not in c):
|
||||||
|
if str(out.get("command", "")) == c:
|
||||||
|
out["command"] = resolved
|
||||||
|
return out, f"command → {resolved}"
|
||||||
|
args = list(out.get("args", []) or [])
|
||||||
|
for i, a in enumerate(args):
|
||||||
|
if a == c:
|
||||||
|
args[i] = resolved
|
||||||
|
out["args"] = args
|
||||||
|
return out, f"'{c}' → {resolved}"
|
||||||
|
return data, None
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PySide6>=6.6
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import json, tempfile, shutil
|
||||||
|
from pathlib import Path
|
||||||
|
import bcc_core as c
|
||||||
|
|
||||||
|
tmp = Path(tempfile.mkdtemp())
|
||||||
|
ok = []
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
ok.append(cond)
|
||||||
|
print(("PASS" if cond else "FAIL"), "-", name)
|
||||||
|
|
||||||
|
# ---- 1. discovery (monkeypatch the base dir) ----
|
||||||
|
base = tmp / "AppSupport"
|
||||||
|
for d in ("Claude", "Claude-Work", "NotClaude"):
|
||||||
|
(base / d).mkdir(parents=True)
|
||||||
|
(base / "Claude" / c.CONFIG_FILENAME).write_text("{}")
|
||||||
|
# Claude-Work has no config yet; NotClaude shouldn't match
|
||||||
|
c.app_support_base = lambda: base
|
||||||
|
profs = c.discover_profiles()
|
||||||
|
labels = sorted(p.label for p in profs)
|
||||||
|
check("discovers Claude + Claude-Work, not NotClaude", labels == ["Claude", "Claude-Work"])
|
||||||
|
check("flags missing config file", any(p.label=="Claude-Work" and not p.config_exists for p in profs))
|
||||||
|
|
||||||
|
# ---- 2. write preserves OTHER keys + order, only touches mcpServers ----
|
||||||
|
cfgpath = tmp / "real.json"
|
||||||
|
original = {
|
||||||
|
"globalShortcut": "Cmd+Shift+Space",
|
||||||
|
"mcpServers": {"old": {"command": "node", "args": ["x.js"]}},
|
||||||
|
"someOtherTool": {"keep": True},
|
||||||
|
}
|
||||||
|
cfgpath.write_text(json.dumps(original, indent=2))
|
||||||
|
cfg = c.load_config(cfgpath)
|
||||||
|
servers = c.extract_servers(cfg)
|
||||||
|
# add a new one, disable the old one
|
||||||
|
servers.append(c.ServerEntry("brave", {"command": "npx", "args": ["-y", "@x/brave"]}, True))
|
||||||
|
servers[0].enabled = False
|
||||||
|
c.apply_servers(cfg, servers)
|
||||||
|
c.write_config(cfgpath, cfg)
|
||||||
|
written = json.loads(cfgpath.read_text())
|
||||||
|
keys = list(written.keys())
|
||||||
|
check("preserved globalShortcut", written.get("globalShortcut") == "Cmd+Shift+Space")
|
||||||
|
check("preserved someOtherTool", written.get("someOtherTool") == {"keep": True})
|
||||||
|
check("disabled server parked in _disabledMcpServers", "old" in written.get("_disabledMcpServers", {}))
|
||||||
|
check("enabled server in mcpServers", "brave" in written.get("mcpServers", {}))
|
||||||
|
check("old not in active mcpServers", "old" not in written.get("mcpServers", {}))
|
||||||
|
check("key order kept (globalShortcut before mcpServers)", keys.index("globalShortcut") < keys.index("mcpServers"))
|
||||||
|
check("backup created", (cfgpath.parent / c.BACKUP_DIRNAME).is_dir() and any((cfgpath.parent / c.BACKUP_DIRNAME).iterdir()))
|
||||||
|
|
||||||
|
# ---- 3. paste parser, all shapes ----
|
||||||
|
full = '{"mcpServers": {"a": {"command": "node", "args": ["a.js"]}}}'
|
||||||
|
check("parse full config shape", list(c.parse_pasted_json(full)) == ["a"])
|
||||||
|
inner = '{"b": {"command": "uvx", "args": ["mcp-server-git"]}}'
|
||||||
|
check("parse inner block shape", list(c.parse_pasted_json(inner)) == ["b"])
|
||||||
|
bare = '{"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]}'
|
||||||
|
parsed = c.parse_pasted_json(bare)
|
||||||
|
check("parse bare server + suggests name 'filesystem'", "filesystem" in parsed)
|
||||||
|
remote = '{"url": "https://mcp.asana.com/sse"}'
|
||||||
|
check("parse remote, suggests host name", "mcp" in c.parse_pasted_json(remote))
|
||||||
|
try:
|
||||||
|
c.parse_pasted_json('{"junk": 5}')
|
||||||
|
check("rejects junk", False)
|
||||||
|
except ValueError:
|
||||||
|
check("rejects junk", True)
|
||||||
|
|
||||||
|
# ---- 4. validation ----
|
||||||
|
bad = [c.ServerEntry("", {"command": "x"}, True),
|
||||||
|
c.ServerEntry("dup", {"command": "x"}, True),
|
||||||
|
c.ServerEntry("dup", {"command": "x"}, True),
|
||||||
|
c.ServerEntry("r", {"url": ""}, True),
|
||||||
|
c.ServerEntry("nocmd", {"command": ""}, True)]
|
||||||
|
probs = c.validate_servers(bad)
|
||||||
|
check("validation catches empty name", any("empty name" in p for p in probs))
|
||||||
|
check("validation catches duplicate", any("Duplicate" in p for p in probs))
|
||||||
|
check("validation catches missing url", any("no URL" in p for p in probs))
|
||||||
|
check("validation catches missing command", any("no command" in p for p in probs))
|
||||||
|
check("valid set passes clean", c.validate_servers([c.ServerEntry("good", {"command":"node"}, True)]) == [])
|
||||||
|
|
||||||
|
# ---- 5. dependency check (python3 exists; bogusxyz does not) ----
|
||||||
|
r_ok = c.check_dependency({"command": "python3"})
|
||||||
|
check("dep check finds python3", r_ok["status"] == "ok")
|
||||||
|
r_missing = c.check_dependency({"command": "definitely-not-real-binary-xyz"})
|
||||||
|
check("dep check flags missing", r_missing["status"] == "missing")
|
||||||
|
r_remote = c.check_dependency({"url": "https://example.com/mcp"})
|
||||||
|
check("dep check marks remote", r_remote["status"] == "remote")
|
||||||
|
# windows-style wrapper: cmd /c npx ... -> should look past cmd to npx
|
||||||
|
r_wrap = c.check_dependency({"command": "cmd", "args": ["/c", "definitely-not-real-xyz", "foo"]})
|
||||||
|
check("dep check sees through shell wrapper", "definitely-not-real-xyz" in r_wrap["label"])
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"{sum(ok)}/{len(ok)} passed")
|
||||||
|
shutil.rmtree(tmp)
|
||||||
Reference in New Issue
Block a user