2026-04-28 06:09:47 -07:00
|
|
|
"""Install LitterBox MCP into one or more MCP clients.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
py install_mcp.py --list
|
|
|
|
|
py install_mcp.py --install claude-code-project
|
|
|
|
|
py install_mcp.py --install claude-desktop cursor
|
|
|
|
|
py install_mcp.py --install all
|
|
|
|
|
py install_mcp.py --uninstall cursor
|
|
|
|
|
py install_mcp.py --print
|
|
|
|
|
|
|
|
|
|
The script:
|
|
|
|
|
- Picks the project's venv Python (../venv/Scripts/python.exe) when available,
|
|
|
|
|
falling back to $VIRTUAL_ENV, then sys.executable.
|
|
|
|
|
- Resolves an absolute path to LitterBoxMCP.py — required because MCP clients
|
|
|
|
|
spawn the server with no inherited CWD.
|
|
|
|
|
- Reads any existing client config, merges the LitterBox entry alongside other
|
|
|
|
|
servers (idempotent), and writes back. Never clobbers unrelated entries.
|
|
|
|
|
- Verifies `mcp` and `requests` are importable from the chosen Python so the
|
|
|
|
|
server actually has a chance of starting.
|
|
|
|
|
"""
|
|
|
|
|
import argparse
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
2026-05-03 12:46:38 -07:00
|
|
|
from typing import Dict, List
|
2026-04-28 06:09:47 -07:00
|
|
|
|
|
|
|
|
# Server identity used as the dict key inside each client's "mcpServers" map.
|
|
|
|
|
MCP_SERVER_NAME = "litterbox"
|
|
|
|
|
|
|
|
|
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
|
|
|
|
SERVER_SCRIPT = SCRIPT_DIR / "LitterBoxMCP.py"
|
|
|
|
|
REPO_ROOT = SCRIPT_DIR.parent.resolve()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
# Python executable resolution
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def get_python_executable() -> str:
|
|
|
|
|
"""Return the absolute path to the Python that should run the MCP server.
|
|
|
|
|
|
|
|
|
|
Order of preference:
|
|
|
|
|
1. The repo's bundled venv (../venv) if it exists.
|
|
|
|
|
2. $VIRTUAL_ENV from the current shell.
|
|
|
|
|
3. sys.executable (whatever's running this script).
|
|
|
|
|
"""
|
|
|
|
|
candidates: List[Path] = []
|
|
|
|
|
|
|
|
|
|
if sys.platform == "win32":
|
|
|
|
|
candidates.append(REPO_ROOT / "venv" / "Scripts" / "python.exe")
|
|
|
|
|
else:
|
|
|
|
|
candidates.append(REPO_ROOT / "venv" / "bin" / "python3")
|
|
|
|
|
|
|
|
|
|
venv_env = os.environ.get("VIRTUAL_ENV")
|
|
|
|
|
if venv_env:
|
|
|
|
|
if sys.platform == "win32":
|
|
|
|
|
candidates.append(Path(venv_env) / "Scripts" / "python.exe")
|
|
|
|
|
else:
|
|
|
|
|
candidates.append(Path(venv_env) / "bin" / "python3")
|
|
|
|
|
|
|
|
|
|
for c in candidates:
|
|
|
|
|
if c.exists():
|
|
|
|
|
return str(c)
|
|
|
|
|
return sys.executable
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_server_entry() -> dict:
|
|
|
|
|
"""The dict that goes under <client>.mcpServers.litterbox."""
|
|
|
|
|
return {
|
|
|
|
|
"type": "stdio",
|
|
|
|
|
"command": get_python_executable(),
|
|
|
|
|
"args": [str(SERVER_SCRIPT)],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
# Client registry
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
#
|
|
|
|
|
# Each client maps to:
|
|
|
|
|
# path: absolute path to its config JSON
|
|
|
|
|
# structure: list of nested keys leading to the "mcpServers"-equivalent map
|
|
|
|
|
# (e.g. ["servers"] for VS Code, ["mcpServers"] for everyone else)
|
|
|
|
|
# scope: "project" or "global" — informational, used by --list
|
|
|
|
|
#
|
|
|
|
|
# Order matters: --install all installs in this order.
|
|
|
|
|
|
|
|
|
|
def _appdata() -> Path:
|
|
|
|
|
return Path(os.environ.get("APPDATA", "")) if sys.platform == "win32" else Path()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _claude_desktop_path() -> Path:
|
|
|
|
|
if sys.platform == "win32":
|
|
|
|
|
return _appdata() / "Claude" / "claude_desktop_config.json"
|
|
|
|
|
if sys.platform == "darwin":
|
|
|
|
|
return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
|
|
|
return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_clients() -> Dict[str, dict]:
|
|
|
|
|
home = Path.home()
|
|
|
|
|
return {
|
|
|
|
|
"claude-code-project": {
|
|
|
|
|
"path": REPO_ROOT / ".mcp.json",
|
|
|
|
|
"structure": ["mcpServers"],
|
|
|
|
|
"scope": "project",
|
|
|
|
|
},
|
|
|
|
|
"claude-code-global": {
|
|
|
|
|
"path": home / ".claude.json",
|
|
|
|
|
"structure": ["mcpServers"],
|
|
|
|
|
"scope": "global",
|
|
|
|
|
},
|
|
|
|
|
"claude-desktop": {
|
|
|
|
|
"path": _claude_desktop_path(),
|
|
|
|
|
"structure": ["mcpServers"],
|
|
|
|
|
"scope": "global",
|
|
|
|
|
},
|
|
|
|
|
"cursor": {
|
|
|
|
|
"path": home / ".cursor" / "mcp.json",
|
|
|
|
|
"structure": ["mcpServers"],
|
|
|
|
|
"scope": "global",
|
|
|
|
|
},
|
|
|
|
|
"windsurf": {
|
|
|
|
|
"path": home / ".codeium" / "windsurf" / "mcp_config.json",
|
|
|
|
|
"structure": ["mcpServers"],
|
|
|
|
|
"scope": "global",
|
|
|
|
|
},
|
|
|
|
|
"vscode-project": {
|
|
|
|
|
"path": REPO_ROOT / ".vscode" / "mcp.json",
|
|
|
|
|
"structure": ["servers"], # VS Code project mcp.json uses {"servers": {...}}
|
|
|
|
|
"scope": "project",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_client_keys(requested: List[str]) -> List[str]:
|
|
|
|
|
"""Map user input ("all", aliases, exact keys) to canonical client keys."""
|
|
|
|
|
clients = get_clients()
|
|
|
|
|
if requested == ["all"]:
|
|
|
|
|
return list(clients.keys())
|
|
|
|
|
|
|
|
|
|
aliases = {
|
|
|
|
|
"claude-code": "claude-code-project",
|
|
|
|
|
"claude": "claude-desktop",
|
|
|
|
|
"vs-code": "vscode-project",
|
|
|
|
|
"vscode": "vscode-project",
|
|
|
|
|
}
|
|
|
|
|
out: List[str] = []
|
|
|
|
|
for name in requested:
|
|
|
|
|
canonical = aliases.get(name, name)
|
|
|
|
|
if canonical not in clients:
|
|
|
|
|
print(f"Unknown client: {name!r}. Run with --list to see options.", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
if canonical not in out:
|
|
|
|
|
out.append(canonical)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
# JSON read / write helpers (idempotent, atomic)
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def read_json(path: Path) -> dict:
|
|
|
|
|
if not path.exists():
|
|
|
|
|
return {}
|
|
|
|
|
text = path.read_text(encoding="utf-8").strip()
|
|
|
|
|
if not text:
|
|
|
|
|
return {}
|
|
|
|
|
try:
|
|
|
|
|
return json.loads(text)
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
print(f"ERROR: existing config is invalid JSON: {path}\n {e}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_json(path: Path, data: dict) -> None:
|
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
|
|
|
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
tmp.replace(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def walk(config: dict, structure: List[str]) -> dict:
|
|
|
|
|
"""Walk into nested keys, creating dicts as needed. Returns the inner map."""
|
|
|
|
|
cur = config
|
|
|
|
|
for key in structure:
|
|
|
|
|
cur = cur.setdefault(key, {})
|
|
|
|
|
if not isinstance(cur, dict):
|
|
|
|
|
raise SystemExit(f"Config has non-dict at {key!r}; refusing to overwrite.")
|
|
|
|
|
return cur
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
# Dependency check
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def check_dependencies(python: str) -> List[str]:
|
|
|
|
|
"""Return list of importable package names that are missing."""
|
|
|
|
|
missing: List[str] = []
|
|
|
|
|
for pkg in ("mcp", "requests"):
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
[python, "-c", f"import {pkg}"],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
)
|
|
|
|
|
if result.returncode != 0:
|
|
|
|
|
missing.append(pkg)
|
|
|
|
|
return missing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
# Install / uninstall / list
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def install(client_keys: List[str]) -> None:
|
|
|
|
|
clients = get_clients()
|
|
|
|
|
entry = generate_server_entry()
|
|
|
|
|
python = entry["command"]
|
|
|
|
|
|
|
|
|
|
missing = check_dependencies(python)
|
|
|
|
|
if missing:
|
|
|
|
|
print(f"WARNING: {python} is missing: {', '.join(missing)}")
|
|
|
|
|
print(f" Install with: {python} -m pip install {' '.join(missing)}")
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
for key in client_keys:
|
|
|
|
|
spec = clients[key]
|
|
|
|
|
path: Path = spec["path"]
|
|
|
|
|
config = read_json(path)
|
|
|
|
|
servers = walk(config, spec["structure"])
|
|
|
|
|
servers[MCP_SERVER_NAME] = entry
|
|
|
|
|
write_json(path, config)
|
|
|
|
|
print(f" Installed {key} -> {path}")
|
|
|
|
|
|
|
|
|
|
print()
|
|
|
|
|
print("Restart any running MCP clients for changes to take effect.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def uninstall(client_keys: List[str]) -> None:
|
|
|
|
|
clients = get_clients()
|
|
|
|
|
for key in client_keys:
|
|
|
|
|
spec = clients[key]
|
|
|
|
|
path: Path = spec["path"]
|
|
|
|
|
if not path.exists():
|
|
|
|
|
print(f" Skipped {key} (no config at {path})")
|
|
|
|
|
continue
|
|
|
|
|
config = read_json(path)
|
|
|
|
|
try:
|
|
|
|
|
servers = walk(config, spec["structure"])
|
|
|
|
|
except SystemExit:
|
|
|
|
|
continue
|
|
|
|
|
if MCP_SERVER_NAME in servers:
|
|
|
|
|
del servers[MCP_SERVER_NAME]
|
|
|
|
|
write_json(path, config)
|
|
|
|
|
print(f" Removed {key} from {path}")
|
|
|
|
|
else:
|
|
|
|
|
print(f" Skipped {key} (not installed)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_clients() -> None:
|
|
|
|
|
python = get_python_executable()
|
|
|
|
|
print(f"Python: {python}")
|
|
|
|
|
print(f"Server: {SERVER_SCRIPT}")
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
missing = check_dependencies(python)
|
|
|
|
|
if missing:
|
|
|
|
|
print(f"WARNING: {python} is missing: {', '.join(missing)}")
|
|
|
|
|
print(f" Install with: {python} -m pip install {' '.join(missing)}")
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
print("Clients (run with --install <key>):")
|
|
|
|
|
print()
|
|
|
|
|
print(f" {'KEY':<25} {'SCOPE':<8} {'STATUS':<14} CONFIG PATH")
|
|
|
|
|
print(f" {'-' * 23:<25} {'-' * 6:<8} {'-' * 12:<14} {'-' * 11}")
|
|
|
|
|
for key, spec in get_clients().items():
|
|
|
|
|
path: Path = spec["path"]
|
|
|
|
|
if path.exists():
|
|
|
|
|
try:
|
|
|
|
|
config = read_json(path)
|
|
|
|
|
cur = config
|
|
|
|
|
for k in spec["structure"]:
|
|
|
|
|
cur = cur.get(k, {}) if isinstance(cur, dict) else {}
|
|
|
|
|
installed = isinstance(cur, dict) and MCP_SERVER_NAME in cur
|
|
|
|
|
status = "installed" if installed else "config-found"
|
|
|
|
|
except SystemExit:
|
|
|
|
|
status = "invalid-json"
|
|
|
|
|
else:
|
|
|
|
|
status = "config-missing"
|
|
|
|
|
print(f" {key:<25} {spec['scope']:<8} {status:<14} {path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
# CLI
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
description="Install LitterBox MCP into one or more MCP clients.",
|
|
|
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
|
|
|
)
|
|
|
|
|
g = parser.add_mutually_exclusive_group(required=True)
|
|
|
|
|
g.add_argument(
|
|
|
|
|
"--list", action="store_true",
|
|
|
|
|
help="List supported clients with detection / install status.",
|
|
|
|
|
)
|
|
|
|
|
g.add_argument(
|
|
|
|
|
"--install", nargs="+", metavar="CLIENT",
|
|
|
|
|
help="Install for one or more clients (or 'all').",
|
|
|
|
|
)
|
|
|
|
|
g.add_argument(
|
|
|
|
|
"--uninstall", nargs="+", metavar="CLIENT",
|
|
|
|
|
help="Uninstall from one or more clients (or 'all').",
|
|
|
|
|
)
|
|
|
|
|
g.add_argument(
|
|
|
|
|
"--print", action="store_true",
|
|
|
|
|
help="Print the MCP config JSON to stdout without writing any files.",
|
|
|
|
|
)
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
if args.list:
|
|
|
|
|
list_clients()
|
|
|
|
|
elif args.print:
|
|
|
|
|
print(json.dumps({"mcpServers": {MCP_SERVER_NAME: generate_server_entry()}}, indent=2))
|
|
|
|
|
elif args.install:
|
|
|
|
|
install(resolve_client_keys(args.install))
|
|
|
|
|
elif args.uninstall:
|
|
|
|
|
uninstall(resolve_client_keys(args.uninstall))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|