feat: MCP server catalog core -- signed, validated, resolvable (#10, #61)
CI / Lint (ruff) (pull_request) Successful in 12s
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 17s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 14s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 39s
CI / Lint (ruff) (pull_request) Successful in 12s
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 17s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 14s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 39s
Phase 1 of the MCP server catalog: pure, GUI-free core functions plus the seed data/catalog.json (20 servers). No GUI wiring in this PR -- bcc.py is untouched; a follow-up PR adds the picker dialog. - load_catalog(): strict json.loads ONLY. The lenient repair pipeline (repair_json_text / parse_pasted_json*) is never used on catalog bytes, by design and by comment, so a signature always authenticates exactly what gets parsed. - validate_catalog(): rejects the whole file (not per-entry) on: bad schema/version types, missing tier-appropriate fields (basic needs config.command+args, link-only needs docs_url and no config), a command allowlist (npx/uvx/docker/node/python/python3 only), -e/--eval/-c denial for node/python, --privileged and root/$HOME volume-mount denial for docker, non-empty env_required values (hard rejection -- secrets never ship in the catalog), secret-looking args (reuses _TOKEN_PREFIXES/_is_secret_value rather than reimplementing), non-https URL fields, and non-ASCII code points in id/command/args (homoglyph defence). - verify_catalog_signature(): Ed25519 via the cryptography package, domain-separated message (the literal prefix "bcc-catalog-v1|" + raw bytes), accepts a match against any key in CATALOG_PUBKEYS (rotation-ready), never raises. - resolve_catalog(): picks the highest version among bundled/cached/remote candidates that EACH independently pass verify + validate -- the bundled catalog gets no implicit trust, closing the hole where an unsigned payload merged to main would win on being local. Anti-rollback (never regress below the best verified candidate already in hand) and anti-freeze (reject a jump of more than 1000 versions) built in. - catalog_entry_to_paste_json() / config_has_unfilled_placeholders(): small pure helpers the future GUI dialog will use to feed a catalog pick into the existing paste-import path and to gate Save on unfilled placeholder tokens. data/catalog.json: the provided 20-server seed, with a signed_at field added at the top level (lives inside the signed payload once real signing lands in #62). Wired into bcc.spec's PyInstaller datas so it bundles into the frozen app. Security requirements from the issue, and where they landed: - Catalog bytes never touch the lenient JSON repair path -- enforced by load_catalog()'s strict json.loads and a comment warning against wiring it in later. - env_required values are a hard rejection when non-empty, not a warning. - Secret-looking args are rejected at validation time, reusing the existing secret-detection helpers instead of duplicating them. - Non-ASCII id/command/args rejected (typosquat/homoglyph defence). - URL fields restricted to https://. - Ed25519 signature verification is domain-separated and never raises. - The bundled catalog is verified at runtime exactly like remote/cached -- no implicit trust for being local. - Anti-rollback and anti-freeze bounds on resolve_catalog's version comparison. Tests: 42 new tests added to tests/test_core.py (full suite: 239 passed, 1 pre-existing unrelated skip). ruff check and ruff format --check both clean.
This commit is contained in:
@@ -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