@@ -31,7 +31,7 @@ a = Analysis(
|
||||
["bcc.py"],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[("icons", "icons")],
|
||||
datas=[("icons", "icons"), ("data/catalog.json", "data")],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
|
||||
+396
@@ -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=<real value>" 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=")
|
||||
|
||||
# <PLACEHOLDER>-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 <PLACEHOLDER>-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 <ALLOWED_DIR>-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)
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"version": 1,
|
||||
"updated": "2026-07-12",
|
||||
"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",
|
||||
"<ALLOWED_DIR>"
|
||||
]
|
||||
},
|
||||
"placeholders": {
|
||||
"<ALLOWED_DIR>": "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."
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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)."
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"--repository",
|
||||
"<REPO_PATH>"
|
||||
]
|
||||
},
|
||||
"placeholders": {
|
||||
"<REPO_PATH>": "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)."
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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@latest"
|
||||
]
|
||||
},
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"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@latest"
|
||||
]
|
||||
},
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"--access-mode=unrestricted"
|
||||
]
|
||||
},
|
||||
"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)."
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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 \u2014 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."
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"--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."
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"-y",
|
||||
"tavily-mcp@latest"
|
||||
]
|
||||
},
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"id": "firecrawl",
|
||||
"display": "Firecrawl",
|
||||
"description": "Turn any website into clean, LLM-ready data with scraping, crawling, and search tools.",
|
||||
"category": "search",
|
||||
"homepage": "https://github.com/firecrawl/firecrawl-mcp-server",
|
||||
"stars": 6924,
|
||||
"official": true,
|
||||
"setup": "basic",
|
||||
"config": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"firecrawl-mcp"
|
||||
]
|
||||
},
|
||||
"placeholders": {},
|
||||
"env_required": {
|
||||
"FIRECRAWL_API_KEY": ""
|
||||
},
|
||||
"docs_url": "https://github.com/firecrawl/firecrawl-mcp-server",
|
||||
"notes": "Repo moved from mendableai/firecrawl-mcp-server to firecrawl/firecrawl-mcp-server. A keyless free tier works for scrape/search with rate limits; set FIRECRAWL_API_URL instead of the key to point at a self-hosted Firecrawl instance."
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"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 \u2014 pods, deployments, logs, Helm releases \u2014 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@latest"
|
||||
]
|
||||
},
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"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@latest"
|
||||
]
|
||||
},
|
||||
"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 \u2014 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."
|
||||
},
|
||||
{
|
||||
"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"
|
||||
],
|
||||
"env": {
|
||||
"GRAFANA_URL": "<GRAFANA_URL>"
|
||||
}
|
||||
},
|
||||
"placeholders": {
|
||||
"<GRAFANA_URL>": "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 \u2014 datasource-related tools may not work correctly on older versions."
|
||||
},
|
||||
{
|
||||
"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."
|
||||
}
|
||||
],
|
||||
"signed_at": "2026-07-12T00:00:00Z"
|
||||
}
|
||||
@@ -7,6 +7,7 @@ license = { file = "LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"PySide6>=6.6",
|
||||
"cryptography>=42.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -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": "<GRAFANA_URL>"}
|
||||
result = c.catalog_entry_to_paste_json(entry)
|
||||
assert result["widget"]["env"] == {"GRAFANA_URL": "<GRAFANA_URL>"}
|
||||
|
||||
|
||||
def test_config_has_unfilled_placeholders_true_for_token():
|
||||
cfg = {"command": "npx", "args": ["-y", "server", "<ALLOWED_DIR>"]}
|
||||
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": "<GRAFANA_URL>"}}
|
||||
assert c.config_has_unfilled_placeholders(cfg) is True
|
||||
|
||||
Reference in New Issue
Block a user