diff --git a/bcc.spec b/bcc.spec index cea2dac..dfd12b8 100644 --- a/bcc.spec +++ b/bcc.spec @@ -31,7 +31,7 @@ a = Analysis( ["bcc.py"], pathex=[], binaries=[], - datas=[("icons", "icons")], + datas=[("icons", "icons"), ("data/catalog.json", "data")], hiddenimports=[], hookspath=[], hooksconfig={}, diff --git a/bcc_core.py b/bcc_core.py index 1bc1ccb..d207ab4 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -32,6 +32,9 @@ from pathlib import Path from typing import NamedTuple from urllib.parse import urlparse +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + CONFIG_FILENAME = "claude_desktop_config.json" # Disabled servers are parked under this non-standard key. Claude Desktop only @@ -2143,3 +2146,396 @@ def restart_claude_desktop() -> RestartResult: if sys.platform.startswith("win"): return _restart_claude_desktop_windows() return RestartResult(False, "Restarting Claude Desktop isn't supported on this platform.") + + +# --------------------------------------------------------------------------- # +# MCP server catalog (issue #10 / #61) +# +# A curated, SIGNED list of ready-to-use MCP server definitions (bundled with +# the app and, later, fetchable/cacheable — see follow-up issues). Every +# function here is pure and defensive: catalog bytes may come from a fetch +# over the network, a disk cache, or the copy frozen into the binary, and +# all three are treated as equally untrusted until their signature verifies. +# --------------------------------------------------------------------------- # + +# Commands a catalog entry's config is allowed to launch. Anything else +# (bash, sh, curl, a raw script interpreter that isn't on this list, ...) +# is rejected by validate_catalog() regardless of how plausible it looks. +CATALOG_ALLOWED_COMMANDS = frozenset({"npx", "uvx", "docker", "node", "python", "python3"}) + +# Ed25519 public keys allowed to sign a catalog, raw 32-byte form. A LIST +# (not a single key) so keys can be rotated without bricking installs that +# still trust an older key: verify_catalog_signature() accepts a match +# against ANY key in this list. +CATALOG_PUBKEYS: list[bytes] = [ + b"\x00" * 32, # TODO: real key from Catalog Console (#62) +] + +# Domain-separation prefix for the signed message. The signature covers +# this prefix + the raw catalog bytes, never the raw bytes alone, so a +# catalog signature can't be replayed against some other byte-for-byte- +# identical payload that means something else in a different context. +_CATALOG_SIG_DOMAIN = b"bcc-catalog-v1|" + +# Top-level fields that must be https:// URLs when present. +_CATALOG_URL_FIELDS = ("homepage", "docs_url", "source") + +# Inline secret-flag=value forms. Distinct from _TOKEN_PREFIXES below -- +# this catches "--api-key=" even when the value itself doesn't +# match a well-known token prefix. +_CATALOG_SECRET_ARG_RE = re.compile(r"(?i)--api[-_]?key=|--token=|--password=") + +# -style tokens the GUI must have the user fill in before Save. +_PLACEHOLDER_RE = re.compile(r"<[^<>\s]+>") + +# How many versions a single accepted catalog jump may leap in one go. Bounds +# a "freeze" attack: a compromised/leaked signing key claiming an absurd +# future version would otherwise permanently outrank every legitimate +# catalog release from then on, since the resolver always prefers the +# highest verified version. +_CATALOG_MAX_VERSION_JUMP = 1000 + + +def load_catalog(raw: bytes | str) -> dict: + """ + Parse catalog bytes/text into a dict using STRICT json.loads ONLY. + + 🔴 CRITICAL: the lenient JSON repair pipeline (repair_json_text, + parse_pasted_json / parse_pasted_json_verbose) must NEVER be wired in + here, or anywhere near catalog handling. That pipeline exists to be + forgiving of hand-pasted snippets from docs and blog posts — smart + quotes, trailing commas, unquoted keys, whatever a human fat-fingered. + Forgiveness is exactly the property a signed payload cannot have: + verify_catalog_signature() authenticates the exact bytes that were + signed. If what gets displayed/executed is a "repaired" reinterpretation + of those bytes rather than the bytes themselves, the signature check + still passes while guaranteeing nothing about what actually runs. Always + verify raw bytes, then load_catalog() those SAME raw bytes. + """ + return json.loads(raw) + + +def catalog_version(data: dict) -> int: + """Extract the integer version from a parsed catalog dict (0 if absent/bad).""" + version = data.get("version") if isinstance(data, dict) else None + return version if isinstance(version, int) and not isinstance(version, bool) else 0 + + +def _secret_looking_arg(a: str) -> bool: + """ + True when a catalog arg string looks like it embeds a real secret. Reuses + the existing token-prefix detector (_is_secret_value / _TOKEN_PREFIXES) + rather than reimplementing it — one definition of "looks like a secret" + for the whole app. + """ + if _CATALOG_SECRET_ARG_RE.search(a): + return True + value = a.split("=", 1)[1] if "=" in a else a + return _is_secret_value(value) or _is_secret_value(a) + + +def _docker_arg_violations(tag: str, args: list[str]) -> list[str]: + """--privileged and volume mounts rooted at / or $HOME are refused.""" + problems: list[str] = [] + if "--privileged" in args: + problems.append(f"{tag}: config.args uses --privileged, which is not allowed.") + + i = 0 + while i < len(args): + a = args[i] + mount = None + if a in ("-v", "--volume") and i + 1 < len(args): + mount = args[i + 1] + i += 1 + elif a.startswith("--volume="): + mount = a.split("=", 1)[1] + elif a.startswith("-v") and a != "-v": + mount = a[2:] + if mount: + source = mount.split(":", 1)[0] + if source in ("/", "$HOME") or source.startswith("$HOME"): + problems.append( + f"{tag}: config.args mounts {source!r}, which is not allowed " + "(volume mounts of / or $HOME are refused)." + ) + i += 1 + return problems + + +def _validate_catalog_config(tag: str, config) -> list[str]: + """Validate the `config` block of a basic-tier catalog entry.""" + if not isinstance(config, dict): + return [f"{tag}: basic entries require a 'config' object with command+args."] + + problems: list[str] = [] + + command = config.get("command") + if not isinstance(command, str) or not command: + problems.append(f"{tag}: config.command must be a non-empty string.") + command = "" + elif not command.isascii(): + problems.append(f"{tag}: config.command must be ASCII (non-ASCII code points rejected).") + + if command and command not in CATALOG_ALLOWED_COMMANDS: + problems.append( + f"{tag}: config.command {command!r} is not on the catalog allowlist " + f"({', '.join(sorted(CATALOG_ALLOWED_COMMANDS))})." + ) + + args = config.get("args") + if not isinstance(args, list) or not all(isinstance(a, str) for a in args): + problems.append(f"{tag}: config.args must be a list of strings.") + args = [] + + for a in args: + if not a.isascii(): + problems.append(f"{tag}: config.args contains a non-ASCII value ({a!r}).") + if _secret_looking_arg(a): + problems.append( + f"{tag}: config.args contains a secret-looking value ({a!r}); " + "secrets belong in env, never args." + ) + + if command in ("node", "python", "python3") and any(a in ("-e", "--eval", "-c") for a in args): + problems.append( + f"{tag}: config.args uses -e/--eval/-c with {command!r}, which is not allowed." + ) + + if command == "docker": + problems.extend(_docker_arg_violations(tag, args)) + + env = config.get("env") + if env is not None and ( + not isinstance(env, dict) or any(not isinstance(v, str) for v in env.values()) + ): + problems.append(f"{tag}: config.env must be an object of string values.") + + return problems + + +def _validate_catalog_entry(idx: int, entry, seen_ids: set[str]) -> list[str]: + """Validate a single `servers[idx]` catalog entry.""" + tag = f"servers[{idx}]" + if not isinstance(entry, dict): + return [f"{tag}: must be an object."] + + problems: list[str] = [] + + entry_id = entry.get("id") + if not isinstance(entry_id, str) or not entry_id.strip(): + problems.append(f"{tag}: 'id' must be a non-empty string.") + else: + tag = f"servers[{idx}] ({entry_id!r})" + if not entry_id.isascii(): + problems.append(f"{tag}: 'id' must be ASCII (non-ASCII code points rejected).") + if entry_id in seen_ids: + problems.append(f"{tag}: duplicate id.") + seen_ids.add(entry_id) + + for field in ("display", "description", "category"): + if not isinstance(entry.get(field), str) or not entry[field].strip(): + problems.append(f"{tag}: '{field}' must be a non-empty string.") + + if not isinstance(entry.get("official"), bool): + problems.append(f"{tag}: 'official' must be a boolean.") + + setup = entry.get("setup") + if setup not in ("basic", "link-only"): + problems.append(f"{tag}: 'setup' must be 'basic' or 'link-only'.") + + env_required = entry.get("env_required") + if not isinstance(env_required, dict): + problems.append(f"{tag}: 'env_required' must be an object.") + else: + for k, v in env_required.items(): + if not isinstance(k, str): + problems.append(f"{tag}: 'env_required' keys must be strings.") + if v != "": + problems.append( + f"{tag}: env_required[{k!r}] must be an empty string — " + "catalog entries never ship secret values, only the names " + "of env vars the user must fill in." + ) + + for field in _CATALOG_URL_FIELDS: + if field in entry and entry[field] is not None: + url = entry[field] + if not isinstance(url, str) or not url.startswith("https://"): + problems.append(f"{tag}: '{field}' must be an https:// URL.") + + if setup == "link-only": + if entry.get("config") is not None: + problems.append(f"{tag}: link-only entries must not have a 'config'.") + docs_url = entry.get("docs_url") + if not isinstance(docs_url, str) or not docs_url.startswith("https://"): + problems.append(f"{tag}: link-only entries require an https:// 'docs_url'.") + elif setup == "basic": + problems.extend(_validate_catalog_config(tag, entry.get("config"))) + + return problems + + +def validate_catalog(data) -> list[str]: + """ + Validate a parsed catalog dict. Returns a list of human-readable + problems; an EMPTY list means the catalog is valid. + + A non-empty list means REJECT THE WHOLE FILE, not just the offending + entry. There is no per-entry salvage here: a catalog that is invalid in + one place is untrusted everywhere, because a caller that tried to keep + "the other 19 entries that looked fine" would need its own judgment call + about which parts of a failed-validation file to trust — exactly the + judgment call this function exists to make once, centrally. + """ + if not isinstance(data, dict): + return ["Catalog root must be a JSON object."] + + problems: list[str] = [] + + schema = data.get("schema") + if not isinstance(schema, int) or isinstance(schema, bool) or schema < 1: + problems.append("'schema' must be a positive integer.") + + version = data.get("version") + if not isinstance(version, int) or isinstance(version, bool) or version < 1: + problems.append("'version' must be a positive integer.") + + servers = data.get("servers") + if not isinstance(servers, list): + problems.append("'servers' must be a list.") + return problems # nothing else to check without a server list + + seen_ids: set[str] = set() + for idx, entry in enumerate(servers): + problems.extend(_validate_catalog_entry(idx, entry, seen_ids)) + + return problems + + +def verify_catalog_signature(raw: bytes, sig: bytes, pubkeys: list[bytes]) -> bool: + """ + Verify an Ed25519 signature over `raw` catalog bytes. + + The signed message is domain-separated: b"bcc-catalog-v1|" + raw, not + raw alone (see _CATALOG_SIG_DOMAIN). + + Returns True if ANY key in `pubkeys` verifies — this is what lets keys + rotate without bricking installs still trusting an older key. + + Never raises. An invalid signature, a garbage/wrong-length key, a + non-bytes argument, an empty signature — all of it just returns False. + Signature verification is exactly the wrong place for an exception to + accidentally propagate into a code path that fails open. + """ + if not isinstance(raw, bytes) or not isinstance(sig, (bytes, bytearray)): + return False + if not sig: + return False + message = _CATALOG_SIG_DOMAIN + raw + for pk in pubkeys or []: + try: + Ed25519PublicKey.from_public_bytes(bytes(pk)).verify(bytes(sig), message) + return True + except (InvalidSignature, ValueError, TypeError): + continue + return False + + +def resolve_catalog( + bundled: tuple[bytes, bytes] | None, + cached: tuple[bytes, bytes] | None, + remote: tuple[bytes, bytes] | None, +) -> dict: + """ + Pick the highest-version catalog among bundled/cached/remote. Each + argument is either None (unavailable) or an (raw_bytes, signature_bytes) + pair. + + 🔴 SECURITY: every candidate — including `bundled`, the copy frozen into + this binary — is verified against CATALOG_PUBKEYS and re-validated from + scratch right here. The bundled catalog gets NO implicit trust. This was + a hole in the original design: bundling data/catalog.json as a plain + asset would let an unsigned/malformed payload that somehow merged to + main ship inside the next release and win the version comparison simply + by virtue of being local. Signing (and checking the signature at + runtime, every time) closes that. + + Anti-rollback: a candidate's version is never accepted if it's lower + than the best verified candidate already found in this same resolution + pass — an attacker replaying an old, since-superseded signed catalog + can't downgrade you. + + Anti-freeze: a candidate whose version leaps more than + _CATALOG_MAX_VERSION_JUMP past the current best is also rejected. A + compromised/leaked signing key claiming an absurd future version would + otherwise permanently outrank every legitimate release from then on, + since the resolver always prefers the highest verified version — this + caps how far a single accepted jump can go. + + Returns the winning catalog dict, or {} if nothing verified and + validated. + """ + best: dict = {} + best_version = -1 + + for candidate in (bundled, cached, remote): + if not candidate: + continue + raw, sig = candidate + if not verify_catalog_signature(raw, sig, CATALOG_PUBKEYS): + continue + try: + data = load_catalog(raw) + except (ValueError, TypeError): + continue + if validate_catalog(data): + continue + + version = catalog_version(data) + if best_version >= 0: + if version < best_version: + continue # anti-rollback + if version > best_version + _CATALOG_MAX_VERSION_JUMP: + continue # anti-freeze + + best = data + best_version = version + + return best + + +def catalog_entry_to_paste_json(entry: dict) -> dict: + """ + Convert a basic-tier catalog entry into the {name: {command, args, env}} + shape parse_pasted_json()/_import_server() already understand, so the + (future) catalog picker dialog can feed a selection straight into the + existing paste-import path instead of growing a parallel one. + """ + config = entry.get("config") or {} + name = entry.get("id") or entry.get("display") or "server" + data: dict = { + "command": config.get("command", ""), + "args": list(config.get("args") or []), + } + env = config.get("env") + if env: + data["env"] = dict(env) + return {str(name): data} + + +def config_has_unfilled_placeholders(cfg: dict) -> bool: + """ + True if any -style token remains anywhere in a server + config's command/args/env (the shape produced by + catalog_entry_to_paste_json). The GUI uses this to refuse Save until + every -style token has been filled in with a real value. + """ + values: list[str] = [] + cmd = cfg.get("command") + if isinstance(cmd, str): + values.append(cmd) + values.extend(a for a in (cfg.get("args") or []) if isinstance(a, str)) + env = cfg.get("env") or {} + if isinstance(env, dict): + values.extend(v for v in env.values() if isinstance(v, str)) + return any(_PLACEHOLDER_RE.search(v) for v in values) diff --git a/data/catalog.json b/data/catalog.json new file mode 100644 index 0000000..b6ca9e0 --- /dev/null +++ b/data/catalog.json @@ -0,0 +1,454 @@ +{ + "schema": 1, + "version": 1, + "updated": "2026-07-12", + "signed_at": "2026-07-12T21:35:19Z", + "servers": [ + { + "id": "filesystem", + "display": "Filesystem", + "description": "Lets Claude read, write, search, and edit files in directories you explicitly allow.", + "category": "files", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", + "stars": 85995, + "official": true, + "setup": "basic", + "config": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem@2026.7.10", + "" + ] + }, + "placeholders": { + "": "Absolute path to a directory the server may read/write. Add more directories as additional args." + }, + "env_required": {}, + "docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", + "notes": "Part of the official modelcontextprotocol/servers reference monorepo (star count is for the whole repo). Clients that support MCP 'roots' can also grant directories dynamically instead of via args.", + "last_release": "2026-07-10" + }, + { + "id": "fetch", + "display": "Fetch", + "description": "Fetches a URL and converts the page to clean markdown so Claude can read web content.", + "category": "dev", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", + "stars": 85995, + "official": true, + "setup": "basic", + "config": { + "command": "uvx", + "args": [ + "mcp-server-fetch@2026.7.10" + ] + }, + "placeholders": {}, + "env_required": {}, + "docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch", + "notes": "Can access local/internal IPs, so treat as a mild security risk on untrusted networks. Add '--ignore-robots-txt' or '--user-agent=...' as extra args if needed.", + "last_release": "2026-07-10" + }, + { + "id": "memory", + "display": "Memory", + "description": "Gives Claude a persistent knowledge-graph memory that survives across conversations.", + "category": "ai", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory", + "stars": 85995, + "official": true, + "setup": "basic", + "config": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-memory@2026.7.4" + ] + }, + "placeholders": {}, + "env_required": {}, + "docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory", + "notes": "Stores entities/relations as a local JSON knowledge graph (path configurable via MEMORY_FILE_PATH env var).", + "last_release": "2026-07-04" + }, + { + "id": "sequential-thinking", + "display": "Sequential Thinking", + "description": "Gives Claude a structured, step-by-step reasoning tool for breaking down complex problems.", + "category": "ai", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking", + "stars": 85995, + "official": true, + "setup": "basic", + "config": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-sequential-thinking@2026.7.4" + ] + }, + "placeholders": {}, + "env_required": {}, + "docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking", + "notes": "Set DISABLE_THOUGHT_LOGGING=true to stop it from printing each thought step to the console.", + "last_release": "2026-07-04" + }, + { + "id": "git", + "display": "Git", + "description": "Lets Claude read history, diff, branch, and search a local git repository.", + "category": "dev", + "homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git", + "stars": 85995, + "official": true, + "setup": "basic", + "config": { + "command": "uvx", + "args": [ + "mcp-server-git@2026.7.10", + "--repository", + "" + ] + }, + "placeholders": { + "": "Absolute path to the local git repository" + }, + "env_required": {}, + "docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/git", + "notes": "Operates on a local checkout only; it does not talk to GitHub/GitLab APIs (pair with the GitHub or GitLab server for that).", + "last_release": "2026-07-10" + }, + { + "id": "github", + "display": "GitHub", + "description": "Lets Claude read repos/code, and manage issues, pull requests, and Actions on GitHub.", + "category": "code-hosting", + "homepage": "https://github.com/github/github-mcp-server", + "stars": 30202, + "official": true, + "setup": "basic", + "config": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server:v1.0.1" + ] + }, + "placeholders": {}, + "env_required": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "" + }, + "docs_url": "https://github.com/github/github-mcp-server/blob/main/docs/installation-guides/README.md", + "notes": "The old '@modelcontextprotocol/server-github' npm package is discontinued (deprecated April 2025). GitHub now ships a Docker-based local server (requires Docker installed/running) plus a hosted remote server at https://api.githubcopilot.com/mcp/ that supports OAuth or PAT auth without Docker." + }, + { + "id": "playwright", + "display": "Playwright", + "description": "Lets Claude drive a real browser (click, type, navigate, screenshot) using Playwright's accessibility-tree snapshots.", + "category": "browser", + "homepage": "https://github.com/microsoft/playwright-mcp", + "stars": 34000, + "official": true, + "setup": "basic", + "config": { + "command": "npx", + "args": [ + "@playwright/mcp@0.0.78" + ] + }, + "placeholders": {}, + "env_required": {}, + "docs_url": "https://github.com/microsoft/playwright-mcp#readme", + "notes": "Maintained by the Playwright team at Microsoft. Add '--isolated' for a throwaway profile, or '--browser firefox|webkit|msedge' to change engine. A persistent browser profile is used by default so logins carry over between sessions.", + "last_release": "2026-07-09" + }, + { + "id": "chrome-devtools", + "display": "Chrome DevTools", + "description": "Lets Claude control Chrome and use real DevTools features: performance traces, network inspection, console logs, screenshots.", + "category": "browser", + "homepage": "https://github.com/ChromeDevTools/chrome-devtools-mcp", + "stars": 45000, + "official": true, + "setup": "basic", + "config": { + "command": "npx", + "args": [ + "-y", + "chrome-devtools-mcp@1.5.0" + ] + }, + "placeholders": {}, + "env_required": {}, + "docs_url": "https://github.com/ChromeDevTools/chrome-devtools-mcp#readme", + "notes": "Maintained by the Google Chrome DevTools team; only officially supports Google Chrome / Chrome for Testing. Exposes the browser's content to the MCP client, so avoid sensitive sites while connected. Add '--slim --headless' for a minimal 3-tool basic-automation mode.", + "last_release": "2026-07-03" + }, + { + "id": "postgres", + "display": "Postgres MCP Pro", + "description": "Lets Claude query, inspect schema, and analyze/tune performance of a PostgreSQL database.", + "category": "database", + "homepage": "https://github.com/crystaldba/postgres-mcp", + "stars": 2400, + "official": false, + "setup": "basic", + "config": { + "command": "uvx", + "args": [ + "postgres-mcp@0.3.0", + "--access-mode=restricted" + ] + }, + "placeholders": {}, + "env_required": { + "DATABASE_URI": "" + }, + "docs_url": "https://github.com/crystaldba/postgres-mcp#readme", + "notes": "The official reference Postgres server was archived by the MCP team; this community server (Crystal DBA) is the most capable/most-referenced replacement, adding index tuning and EXPLAIN-plan analysis. Use --access-mode=restricted for read-only/production use. Docker image also available (crystaldba/postgres-mcp). Catalog ships --access-mode=restricted (read-only); switch to unrestricted yourself if you want writes.", + "last_release": "2025-05-16" + }, + { + "id": "n8n", + "display": "n8n", + "description": "Build, validate, and deploy n8n workflows with full node documentation for the AI.", + "category": "infra", + "homepage": "https://github.com/czlonkowski/n8n-mcp", + "stars": 22257, + "official": false, + "setup": "basic", + "config": { + "command": "npx", + "args": [ + "n8n-mcp@2.63.2" + ] + }, + "placeholders": {}, + "env_required": { + "MCP_MODE": "", + "N8N_API_URL": "", + "N8N_API_KEY": "" + }, + "docs_url": "https://github.com/czlonkowski/n8n-mcp", + "notes": "Set MCP_MODE=stdio (required for Claude Desktop, prevents debug logs from breaking the protocol). N8N_API_URL/N8N_API_KEY are optional — without them you still get full node documentation, validation, and template search; with them you get live workflow create/update/execute against your own n8n instance. A hosted free-tier alternative exists at dashboard.n8n-mcp.com.", + "last_release": "2026-07-09" + }, + { + "id": "notion", + "display": "Notion", + "description": "Read, search, and edit Notion pages, databases, and comments from your AI assistant.", + "category": "productivity", + "homepage": "https://github.com/makenotion/notion-mcp-server", + "stars": 4400, + "official": true, + "setup": "basic", + "config": { + "command": "npx", + "args": [ + "-y", + "@notionhq/notion-mcp-server@2.4.1" + ] + }, + "placeholders": {}, + "env_required": { + "NOTION_TOKEN": "" + }, + "docs_url": "https://developers.notion.com/docs/mcp", + "notes": "Notion is prioritizing its hosted remote MCP (OAuth, https://mcp.notion.com/mcp) and may eventually sunset this local package, but the stdio server still works today and is the simplest way to get a static config with an internal-integration token.", + "last_release": "2026-06-22" + }, + { + "id": "obsidian", + "display": "Obsidian", + "description": "Read, search, and edit notes in your Obsidian vault.", + "category": "personal", + "homepage": "https://github.com/MarkusPfundstein/mcp-obsidian", + "stars": 4067, + "official": false, + "setup": "basic", + "config": { + "command": "uvx", + "args": [ + "mcp-obsidian@0.2.2" + ] + }, + "placeholders": {}, + "env_required": { + "OBSIDIAN_API_KEY": "", + "OBSIDIAN_HOST": "", + "OBSIDIAN_PORT": "" + }, + "docs_url": "https://github.com/MarkusPfundstein/mcp-obsidian", + "notes": "Requires the Obsidian Local REST API community plugin installed and enabled in Obsidian; copy the API key from the plugin settings. OBSIDIAN_HOST defaults to 127.0.0.1 and OBSIDIAN_PORT to 27124 if omitted.", + "last_release": "2025-04-01" + }, + { + "id": "brave-search", + "display": "Brave Search", + "description": "Search the web, news, images, and videos using Brave's independent search index.", + "category": "search", + "homepage": "https://github.com/brave/brave-search-mcp-server", + "stars": 1288, + "official": true, + "setup": "basic", + "config": { + "command": "npx", + "args": [ + "-y", + "@brave/brave-search-mcp-server@2.0.85", + "--transport", + "stdio" + ] + }, + "placeholders": {}, + "env_required": { + "BRAVE_API_KEY": "" + }, + "docs_url": "https://github.com/brave/brave-search-mcp-server", + "notes": "Official Brave server; replaced the old archived modelcontextprotocol/servers brave-search entry (now in modelcontextprotocol/servers-archived). Get an API key from the Brave Search API dashboard.", + "last_release": "2026-06-15" + }, + { + "id": "tavily", + "display": "Tavily", + "description": "AI-optimized web search, extract, map, and crawl API built for LLM agents.", + "category": "search", + "homepage": "https://github.com/tavily-ai/tavily-mcp", + "stars": 2206, + "official": true, + "setup": "basic", + "config": { + "command": "npx", + "args": [ + "tavily-mcp@0.2.21" + ] + }, + "placeholders": {}, + "env_required": { + "TAVILY_API_KEY": "" + }, + "docs_url": "https://github.com/tavily-ai/tavily-mcp", + "notes": "Tavily also offers a hosted remote MCP endpoint (mcp.tavily.com) with OAuth as an alternative to running the local npx server.", + "last_release": "2026-07-10" + }, + { + "id": "home-assistant", + "display": "Home Assistant", + "description": "Control smart home devices, query states, and troubleshoot automations in Home Assistant.", + "category": "smart-home", + "homepage": "https://github.com/voska/hass-mcp", + "stars": 308, + "official": false, + "setup": "basic", + "config": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "HA_URL", + "-e", + "HA_TOKEN", + "voska/hass-mcp:0.5.0" + ] + }, + "placeholders": {}, + "env_required": { + "HA_URL": "", + "HA_TOKEN": "" + }, + "docs_url": "https://github.com/voska/hass-mcp", + "notes": "HA_URL example: http://homeassistant.local:8123 (use http://host.docker.internal:8123 if HA runs in Docker on the same machine). HA_TOKEN is a Home Assistant long-lived access token from your profile page. A more actively developed alternative is the community 'HA-MCP' integration (homeassistant-ai/ha-mcp, ~3.9k stars), but it installs inside Home Assistant itself via HACS rather than as an external stdio process, so it doesn't fit this catalog's launch-line format." + }, + { + "id": "kubernetes", + "display": "Kubernetes", + "description": "Lets Claude inspect and manage Kubernetes/OpenShift resources — pods, deployments, logs, Helm releases — using your local kubeconfig.", + "category": "infra", + "homepage": "https://github.com/containers/kubernetes-mcp-server", + "stars": 1626, + "official": false, + "setup": "basic", + "config": { + "command": "npx", + "args": [ + "-y", + "kubernetes-mcp-server@0.0.64" + ] + }, + "placeholders": {}, + "env_required": {}, + "docs_url": "https://github.com/containers/kubernetes-mcp-server#readme", + "notes": "Not an official Kubernetes SIG project, but a Go-native (no kubectl dependency) implementation maintained under the 'containers' GitHub org (Podman/Red Hat-adjacent) that's widely regarded as the most capable K8s MCP server, supporting Kubernetes and OpenShift. Uses your existing ~/.kube/config automatically; add --read-only to prevent writes.", + "last_release": "2026-07-10" + }, + { + "id": "aws-api-mcp-server", + "display": "AWS API MCP Server (AWS Labs)", + "description": "Lets your AI assistant run AWS CLI commands to inspect and manage AWS resources across virtually every AWS service.", + "category": "cloud", + "homepage": "https://github.com/awslabs/mcp", + "stars": 9431, + "official": true, + "setup": "basic", + "config": { + "command": "uvx", + "args": [ + "awslabs.aws-api-mcp-server@1.3.46" + ] + }, + "placeholders": {}, + "env_required": {}, + "docs_url": "https://awslabs.github.io/mcp/servers/aws-api-mcp-server", + "notes": "AWS credentials are NOT set in this MCP config — configure them beforehand via `aws configure` (or set AWS_API_MCP_PROFILE_NAME to pick a named profile) so boto3's standard credential chain can find them. Optional env vars: AWS_REGION (default us-east-1), READ_OPERATIONS_ONLY=true to block all mutating AWS calls. AWS notes this server is being superseded by a newer unified AWS MCP server referenced in their agent-toolkit docs.", + "last_release": "2026-06-25" + }, + { + "id": "grafana", + "display": "Grafana", + "description": "Query dashboards, datasources, alerts and incidents in Grafana from your AI assistant.", + "category": "observability", + "homepage": "https://github.com/grafana/mcp-grafana", + "stars": 3227, + "official": true, + "setup": "basic", + "config": { + "command": "uvx", + "args": [ + "mcp-grafana@0.17.1" + ], + "env": { + "GRAFANA_URL": "" + } + }, + "placeholders": { + "": "Your Grafana instance URL, e.g. http://localhost:3000 or https://yourstack.grafana.net" + }, + "env_required": { + "GRAFANA_SERVICE_ACCOUNT_TOKEN": "" + }, + "docs_url": "https://grafana.com/docs/grafana/latest/developer-resources/mcp/", + "notes": "Requires Grafana 9.0+ for full functionality — datasource-related tools may not work correctly on older versions.", + "last_release": "2026-07-07" + }, + { + "id": "slack", + "display": "Slack", + "description": "Search Slack messages, files, and members, and send messages or manage channels from your AI assistant.", + "category": "communication", + "homepage": "https://slack.com/help/articles/48855576908307-Guide-to-the-Slack-MCP-server", + "stars": null, + "official": true, + "setup": "link-only", + "env_required": {}, + "docs_url": "https://docs.slack.dev/changelog/2026/02/17/slack-mcp/", + "notes": "Slack's own MCP server went GA Feb 17, 2026 (streamable HTTP at https://mcp.slack.com/mcp, OAuth). No stdio one-liner is published because it's a hosted, permissioned connector. A well-known community alternative, korotovsky/slack-mcp-server (~1.6k GitHub stars, MIT, not an official Slack product), supports stdio/SSE/HTTP with bot or browser-session tokens and no app-install requirement if a stdio option is preferred." + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index 1b0fa31..ce35372 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ license = { file = "LICENSE" } requires-python = ">=3.10" dependencies = [ "PySide6>=6.6", + "cryptography>=42.0", ] [project.optional-dependencies] diff --git a/tests/test_core.py b/tests/test_core.py index b4492c5..8b58cd2 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -10,6 +10,7 @@ import urllib.request from pathlib import Path import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey import bcc_core as c @@ -1668,3 +1669,396 @@ def test_app_icon_assets_present(): assert (rounded / f"icon-{size}.png").is_file(), f"missing icon-{size}.png" assert (root / "icons" / "app.ico").is_file() assert (root / "icons" / "app.icns").is_file() + + +# --------------------------------------------------------------------------- # +# MCP server catalog (issue #10 / #61) +# --------------------------------------------------------------------------- # +def _minimal_catalog(version: int = 1) -> dict: + return { + "schema": 1, + "version": version, + "updated": "2026-07-12", + "servers": [ + { + "id": "widget", + "display": "Widget", + "description": "A test widget server.", + "category": "dev", + "homepage": "https://example.com/widget", + "stars": 10, + "official": True, + "setup": "basic", + "config": { + "command": "npx", + "args": ["-y", "widget-mcp"], + }, + "placeholders": {}, + "env_required": {}, + "docs_url": "https://example.com/widget/docs", + "notes": "", + } + ], + } + + +def _catalog_with(server_overrides: dict) -> dict: + data = _minimal_catalog() + data["servers"][0].update(server_overrides) + return data + + +def _sign(raw: bytes, priv: Ed25519PrivateKey) -> bytes: + # Independent of bcc_core's domain-separation constant on purpose: this + # is the literal wire format the design calls for, hardcoded here so a + # change to the constant would be caught as a real behaviour change. + return priv.sign(b"bcc-catalog-v1|" + raw) + + +def _signed(data: dict, priv: Ed25519PrivateKey) -> tuple[bytes, bytes]: + raw = json.dumps(data).encode("utf-8") + return raw, _sign(raw, priv) + + +# --- load_catalog / validate_catalog: valid round trip ------------------- # +def test_load_catalog_valid_round_trip(): + data = _minimal_catalog() + raw = json.dumps(data).encode("utf-8") + loaded = c.load_catalog(raw) + assert loaded == data + assert c.validate_catalog(loaded) == [] + assert c.catalog_version(loaded) == 1 + + +def test_load_catalog_accepts_str_too(): + data = _minimal_catalog() + text = json.dumps(data) + assert c.load_catalog(text) == data + + +def test_load_catalog_malformed_raises_json_decode_error(): + # load_catalog is strict json.loads ONLY -- it must never silently + # "repair" malformed bytes into something that parses. + with pytest.raises(json.JSONDecodeError): + c.load_catalog(b"{not valid json") + + +def test_shipped_catalog_json_passes_validation(): + """Regression test: the real data/catalog.json bundled with the app.""" + root = Path(c.__file__).resolve().parent + raw = (root / "data" / "catalog.json").read_bytes() + data = c.load_catalog(raw) + problems = c.validate_catalog(data) + assert problems == [], problems + assert c.catalog_version(data) >= 1 + + +# --- verify_catalog_signature --------------------------------------------- # +def test_verify_catalog_signature_valid(): + priv = Ed25519PrivateKey.generate() + pub = priv.public_key().public_bytes_raw() + raw = json.dumps(_minimal_catalog()).encode("utf-8") + sig = _sign(raw, priv) + assert c.verify_catalog_signature(raw, sig, [pub]) is True + + +def test_verify_catalog_signature_tampered_byte_fails(): + priv = Ed25519PrivateKey.generate() + pub = priv.public_key().public_bytes_raw() + raw = json.dumps(_minimal_catalog()).encode("utf-8") + sig = _sign(raw, priv) + tampered = bytearray(raw) + tampered[0] ^= 0xFF # flip exactly one byte + assert c.verify_catalog_signature(bytes(tampered), sig, [pub]) is False + + +def test_verify_catalog_signature_wrong_key_fails(): + priv = Ed25519PrivateKey.generate() + other_pub = Ed25519PrivateKey.generate().public_key().public_bytes_raw() + raw = json.dumps(_minimal_catalog()).encode("utf-8") + sig = _sign(raw, priv) + assert c.verify_catalog_signature(raw, sig, [other_pub]) is False + + +def test_verify_catalog_signature_matches_any_key_in_list(): + priv = Ed25519PrivateKey.generate() + pub = priv.public_key().public_bytes_raw() + other_pub = Ed25519PrivateKey.generate().public_key().public_bytes_raw() + raw = json.dumps(_minimal_catalog()).encode("utf-8") + sig = _sign(raw, priv) + # signing key is second in the list -- rotation support + assert c.verify_catalog_signature(raw, sig, [other_pub, pub]) is True + + +def test_verify_catalog_signature_garbage_sig_fails(): + priv = Ed25519PrivateKey.generate() + pub = priv.public_key().public_bytes_raw() + raw = json.dumps(_minimal_catalog()).encode("utf-8") + assert c.verify_catalog_signature(raw, b"not-a-real-signature", [pub]) is False + assert c.verify_catalog_signature(raw, b"", [pub]) is False + + +def test_verify_catalog_signature_missing_signature_returns_false(): + priv = Ed25519PrivateKey.generate() + pub = priv.public_key().public_bytes_raw() + raw = json.dumps(_minimal_catalog()).encode("utf-8") + assert c.verify_catalog_signature(raw, None, [pub]) is False + + +def test_verify_catalog_signature_never_raises_on_garbage_inputs(): + assert c.verify_catalog_signature(b"", b"", []) is False + assert c.verify_catalog_signature(b"x", b"y", [b"too-short"]) is False + assert c.verify_catalog_signature("not-bytes", b"y", [b"\x00" * 32]) is False + assert c.verify_catalog_signature(b"x", b"y", None) is False + + +# --- validate_catalog: per-rule rejections --------------------------------- # +def test_validate_catalog_rejects_non_dict_root(): + assert c.validate_catalog(["not", "a", "dict"]) != [] + + +def test_validate_catalog_rejects_bad_schema_and_version(): + data = _minimal_catalog() + data["schema"] = 0 + data["version"] = -1 + problems = c.validate_catalog(data) + assert any("schema" in p for p in problems) + assert any("version" in p for p in problems) + + +def test_validate_catalog_basic_requires_config(): + data = _catalog_with({"config": None}) + problems = c.validate_catalog(data) + assert any("config" in p for p in problems) + + +def test_validate_catalog_link_only_forbids_config(): + data = _minimal_catalog() + data["servers"][0] = { + "id": "hosted", + "display": "Hosted", + "description": "A hosted connector.", + "category": "dev", + "homepage": "https://example.com/hosted", + "official": True, + "setup": "link-only", + "env_required": {}, + "docs_url": "https://example.com/hosted/docs", + "notes": "", + "config": {"command": "npx", "args": ["-y", "should-not-be-here"]}, + } + problems = c.validate_catalog(data) + assert any("must not have a 'config'" in p for p in problems) + + +def test_validate_catalog_rejects_disallowed_command(): + data = _catalog_with({"config": {"command": "bash", "args": ["-c", "echo hi"]}}) + problems = c.validate_catalog(data) + assert any("allowlist" in p for p in problems) + + +def test_validate_catalog_rejects_node_eval_flag(): + data = _catalog_with({"config": {"command": "node", "args": ["-e", "require('fs')"]}}) + problems = c.validate_catalog(data) + assert any("-e/--eval/-c" in p for p in problems) + + +def test_validate_catalog_rejects_python_c_flag(): + data = _catalog_with({"config": {"command": "python3", "args": ["-c", "import os"]}}) + problems = c.validate_catalog(data) + assert any("-e/--eval/-c" in p for p in problems) + + +def test_validate_catalog_rejects_docker_privileged(): + data = _catalog_with( + {"config": {"command": "docker", "args": ["run", "--privileged", "some/image"]}} + ) + problems = c.validate_catalog(data) + assert any("--privileged" in p for p in problems) + + +def test_validate_catalog_rejects_docker_root_volume_mount(): + data = _catalog_with( + {"config": {"command": "docker", "args": ["run", "-v", "/:/host", "some/image"]}} + ) + problems = c.validate_catalog(data) + assert any("mounts" in p for p in problems) + + +def test_validate_catalog_rejects_docker_home_volume_mount(): + data = _catalog_with( + {"config": {"command": "docker", "args": ["run", "--volume=$HOME:/host", "some/image"]}} + ) + problems = c.validate_catalog(data) + assert any("mounts" in p for p in problems) + + +def test_validate_catalog_rejects_nonempty_env_required(): + data = _catalog_with({"env_required": {"API_TOKEN": "sk-shouldnotbehere"}}) + problems = c.validate_catalog(data) + assert any("env_required" in p for p in problems) + + +def test_validate_catalog_rejects_secret_looking_arg(): + data = _catalog_with( + {"config": {"command": "npx", "args": ["-y", "widget-mcp", "--api-key=sk-abcdef123"]}} + ) + problems = c.validate_catalog(data) + assert any("secret-looking" in p for p in problems) + + +def test_validate_catalog_rejects_token_prefix_positional_arg(): + data = _catalog_with( + {"config": {"command": "npx", "args": ["-y", "widget-mcp", "ghp_abcdef123456"]}} + ) + problems = c.validate_catalog(data) + assert any("secret-looking" in p for p in problems) + + +def test_validate_catalog_rejects_http_url(): + data = _catalog_with({"homepage": "http://example.com/widget"}) + problems = c.validate_catalog(data) + assert any("homepage" in p for p in problems) + + +def test_validate_catalog_rejects_file_url(): + data = _catalog_with({"docs_url": "file:///etc/passwd"}) + problems = c.validate_catalog(data) + assert any("docs_url" in p for p in problems) + + +def test_validate_catalog_rejects_non_ascii_id(): + data = _catalog_with({"id": "wídget"}) + problems = c.validate_catalog(data) + assert any("ASCII" in p for p in problems) + + +def test_validate_catalog_rejects_non_ascii_command(): + data = _catalog_with({"config": {"command": "npxé", "args": ["-y", "widget-mcp"]}}) + problems = c.validate_catalog(data) + assert any("ASCII" in p for p in problems) + + +def test_validate_catalog_rejects_non_ascii_arg(): + data = _catalog_with({"config": {"command": "npx", "args": ["-y", "wídget-mcp"]}}) + problems = c.validate_catalog(data) + assert any("non-ASCII" in p for p in problems) + + +def test_validate_catalog_rejects_duplicate_ids(): + data = _minimal_catalog() + data["servers"].append(dict(data["servers"][0])) + problems = c.validate_catalog(data) + assert any("duplicate id" in p for p in problems) + + +# --- resolve_catalog -------------------------------------------------------- # +def test_resolve_catalog_nothing_available_returns_empty_dict(): + assert c.resolve_catalog(None, None, None) == {} + + +def test_resolve_catalog_prefers_highest_verified_version(monkeypatch): + priv = Ed25519PrivateKey.generate() + pub = priv.public_key().public_bytes_raw() + monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub]) + + bundled = _signed(_minimal_catalog(version=1), priv) + cached = _signed(_minimal_catalog(version=2), priv) + remote = _signed(_minimal_catalog(version=3), priv) + + result = c.resolve_catalog(bundled, cached, remote) + assert c.catalog_version(result) == 3 + + +def test_resolve_catalog_rejects_unsigned_bundled_catalog(monkeypatch): + priv = Ed25519PrivateKey.generate() + pub = priv.public_key().public_bytes_raw() + monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub]) + + # Bundled claims a very high version but is NOT signed by a trusted key + # -- it must get no implicit trust just for being the local copy. + malicious_raw = json.dumps(_minimal_catalog(version=100)).encode("utf-8") + bundled = (malicious_raw, b"totally-not-a-signature") + + remote = _signed(_minimal_catalog(version=3), priv) + + result = c.resolve_catalog(bundled, None, remote) + assert c.catalog_version(result) == 3 + + +def test_resolve_catalog_rejects_rolled_back_version(monkeypatch): + priv = Ed25519PrivateKey.generate() + pub = priv.public_key().public_bytes_raw() + monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub]) + + cached = _signed(_minimal_catalog(version=5), priv) + rolled_back_remote = _signed(_minimal_catalog(version=2), priv) + + result = c.resolve_catalog(None, cached, rolled_back_remote) + assert c.catalog_version(result) == 5 + + +def test_resolve_catalog_rejects_absurd_version_jump(monkeypatch): + priv = Ed25519PrivateKey.generate() + pub = priv.public_key().public_bytes_raw() + monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub]) + + cached = _signed(_minimal_catalog(version=5), priv) + freeze_attempt = _signed(_minimal_catalog(version=999999), priv) + + result = c.resolve_catalog(None, cached, freeze_attempt) + assert c.catalog_version(result) == 5 + + +def test_resolve_catalog_malformed_candidate_does_not_raise(monkeypatch): + priv = Ed25519PrivateKey.generate() + pub = priv.public_key().public_bytes_raw() + monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub]) + + malformed_raw = b"{not valid json" + malformed_sig = _sign(malformed_raw, priv) + good = _signed(_minimal_catalog(version=1), priv) + + result = c.resolve_catalog((malformed_raw, malformed_sig), None, good) + assert c.catalog_version(result) == 1 + + +def test_resolve_catalog_invalid_but_signed_candidate_is_skipped(monkeypatch): + priv = Ed25519PrivateKey.generate() + pub = priv.public_key().public_bytes_raw() + monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub]) + + invalid = _signed(_catalog_with({"config": {"command": "bash", "args": []}}), priv) + good = _signed(_minimal_catalog(version=1), priv) + + result = c.resolve_catalog(invalid, None, good) + assert c.catalog_version(result) == 1 + + +# --- catalog_entry_to_paste_json / config_has_unfilled_placeholders ------- # +def test_catalog_entry_to_paste_json_basic_shape(): + entry = _minimal_catalog()["servers"][0] + result = c.catalog_entry_to_paste_json(entry) + assert result == {"widget": {"command": "npx", "args": ["-y", "widget-mcp"]}} + + +def test_catalog_entry_to_paste_json_includes_env_when_present(): + entry = _minimal_catalog()["servers"][0] + entry["config"]["env"] = {"GRAFANA_URL": ""} + result = c.catalog_entry_to_paste_json(entry) + assert result["widget"]["env"] == {"GRAFANA_URL": ""} + + +def test_config_has_unfilled_placeholders_true_for_token(): + cfg = {"command": "npx", "args": ["-y", "server", ""]} + assert c.config_has_unfilled_placeholders(cfg) is True + + +def test_config_has_unfilled_placeholders_false_after_fill(): + cfg = {"command": "npx", "args": ["-y", "server", "/Users/me/project"]} + assert c.config_has_unfilled_placeholders(cfg) is False + + +def test_config_has_unfilled_placeholders_checks_env_too(): + cfg = {"command": "uvx", "args": ["mcp-grafana"], "env": {"GRAFANA_URL": ""}} + assert c.config_has_unfilled_placeholders(cfg) is True