Files
litterbox/GrumpyCats/install_mcp.py
T
BlackSnufkin 4922a72c13 Modernize GrumpyCats clients and address upstream issues
GrumpyCats:
- grumpycat.py: replace the 130-line if/elif chain in main() with a
  COMMAND_HANDLERS dispatch table (each subcommand is now a small
  _cmd_* function), parallelize get_comprehensive_results across a
  ThreadPoolExecutor (4 reads in ~one round-trip instead of four),
  add get_risk_assessment for the new /api/results/<target>/risk
  endpoint, drop the unused _file_cache, dead imports
  (hashlib, Tuple, Any), and the unreliable __del__.
- LitterBoxMCP.py: full rewrite onto modern FastMCP. Fixes broken
  import (was pointing at optimized_litterbox_client), replaces the
  removed mcp.serve(host=..., port=...) API with mcp.run(transport=...),
  routes logs to stderr (required for stdio transport), drops the
  handle_api_operation try/except envelope (FastMCP converts
  exceptions to MCP errors automatically), drops the
  LitterBoxMCPClient indirection, makes every tool async with
  asyncio.to_thread, adds Annotated[..., Field(description=...)]
  parameter docs, drops shutdown_client (server lifecycle isn't an
  LLM concern), defaults bind to 127.0.0.1, and trims the 5
  150-200-line OPSEC prompts to 4 focused 15-25-line ones.
- install_mcp.py: new installer modeled on ida-pro-mcp's approach.
  Auto-detects the project venv Python, supports six clients
  (claude-code project + global, claude-desktop, cursor, windsurf,
  vscode-project), idempotent JSON merge that preserves existing
  MCP servers, atomic .tmp+rename writes, dependency check that
  warns if mcp/requests are missing. --list / --install / --uninstall
  / --print modes.
- README.md: rewritten to match — three-component framing, accurate
  dependency list (mcp + requests, not the fictional fastmcp
  package), full installer reference, current 22 MCP tools and 4
  prompts.

Upstream issues + small fixes:
- app/static/js/upload/core.js: wire up the missing
  macroDetectionNotes element (upstream issue: scanning .xls files
  threw "can't access property 'innerHTML', elements.macroDetectionNotes
  is undefined").
- app/templates/report.html: Stringnalyzer section now expands every
  non-empty bucket (URLs, IPs, domains, file paths, etc.) into a full
  code block instead of showing a truncated 3-item, 140-char sample.
  Categories expanded from 7 to 16, capped at 100 items per category
  with "and N more" overflow.
- app/blueprints/api.py: /api/results/<target>/risk endpoint
  returning {risk_score, risk_level, risk_factors} (upstream PR).
- app/templates/dynamic_info.html: Process Telemetry summary panel
  no longer mixes a flush chip-row with an inset 3-column kvgrid;
  uses the lb-hash-row label/value pattern instead and surfaces
  image_path + commandline when RedEdr provides them.
2026-04-28 06:09:47 -07:00

334 lines
11 KiB
Python

"""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
from typing import Dict, List, Tuple
# 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()