feat: MCP server catalog core -- signed, validated, resolvable (#10, #61) #65

Merged
the_og merged 3 commits from feat/10-catalog-core into main 2026-07-12 17:55:49 -04:00
Owner

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. Follows the existing house style: pure/unit-tested logic in bcc_core.py.

What's in bcc_core.py

  • load_catalog(raw) -- strict json.loads ONLY. The lenient repair pipeline (repair_json_text / parse_pasted_json*) is never used on catalog bytes, by design and by an explicit comment warning future editors not to wire it in: a signature only means something if it authenticates the exact bytes that get parsed.
  • validate_catalog(data) -> list[str] -- rejects the whole file (not just the bad entry) on: missing/malformed schema/version/servers; missing tier-appropriate fields (basic needs config.command+args; link-only needs docs_url and must NOT have config); a command allowlist (npx/uvx/docker/node/python/python3 only); -e/--eval/-c denial for node/python; --privileged and /- or $HOME-rooted volume-mount denial for docker; non-empty env_required values as a hard rejection (never a warning -- secrets never ship in the catalog); secret-looking args (reuses the existing _TOKEN_PREFIXES/_is_secret_value rather than reimplementing); non-https:// URL fields (homepage/docs_url/source); and non-ASCII code points in id/command/args (homoglyph/typosquat defence).
  • catalog_version(data) -> int
  • verify_catalog_signature(raw, sig, pubkeys) -> bool -- Ed25519 via the cryptography package (added to pyproject.toml). Domain-separated: the signed message is the literal prefix "bcc-catalog-v1|" + raw bytes, not raw alone. Returns True if ANY key in pubkeys verifies (rotation-ready). Never raises.
  • CATALOG_PUBKEYS: list[bytes] -- module constant seeded with a placeholder + # TODO: real key from Catalog Console (#62); tests generate their own keypairs.
  • resolve_catalog(bundled, cached, remote) -> dict -- picks the highest version among candidates that each independently pass verify + validate. The bundled catalog gets no implicit trust -- it's verified at runtime exactly like remote/cached, closing the hole where an unsigned payload merged to main would win the version comparison just by being local. Anti-rollback (never regress below the best verified candidate already in hand this pass) and anti-freeze (reject a jump of more than 1000 versions) are both enforced.
  • catalog_entry_to_paste_json(entry) -> dict -- basic-tier entry to {name: {command, args, env}}, feeding the existing _import_server path.
  • config_has_unfilled_placeholders(cfg) -> bool -- true if any <PLACEHOLDER> token remains, for the future GUI to gate Save on.

Data

  • 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).
  • bcc.spec's datas now bundles data/catalog.json into the frozen app (same mechanism #20 added for icons).

Security requirements from the issue -- where they landed

  • Catalog bytes never touch the lenient JSON repair path -- enforced by load_catalog()'s strict json.loads plus a comment warning against wiring it in later.
  • env_required non-empty values are a hard rejection, not a warning.
  • Secret-looking args rejected at validation time, reusing existing secret-detection helpers instead of duplicating them.
  • Non-ASCII id/command/args rejected (typosquat/homoglyph defence).
  • URL fields restricted to https://.
  • Ed25519 verification is domain-separated (bcc-catalog-v1| prefix) 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 (1000-version cap) bounds on resolve_catalog's version comparison.
  • Network fetch, on-disk cache, and the GUI dialog are explicitly out of scope for this PR (follow-ups).

Tests

42 new tests added to tests/test_core.py, covering: valid round-trip; tampered-byte signature failure; wrong-key failure; garbage/empty/missing signature; each validator rule's rejection (disallowed command, node -e, python3 -c, docker --privileged, docker /+$HOME volume mounts, non-empty env_required, secret-looking arg incl. token-prefix, http:///file:// URLs, non-ASCII id/command/arg, duplicate ids); malformed JSON handling; the real shipped data/catalog.json passing validate_catalog() (the regression test that matters most); and resolve_catalog preferring the highest verified version while rejecting an unsigned bundled catalog, a rolled-back version, and an absurd version jump.

Full suite: 239 passed, 1 pre-existing unrelated skip (Windows-only test). ruff check and ruff format --check both clean.

Deviations from the brief

  • resolve_catalog's exact parameter shape wasn't fully specified beyond (bundled, cached, remote) -> dict. I made each argument either None or a (raw_bytes, signature_bytes) tuple, verifying+validating from raw bytes inside the function (using the module-level CATALOG_PUBKEYS), and return {} when nothing verifies -- this seemed like the most literal reading that still lets the function independently re-verify every candidate including bundled.
  • No other deviations; everything else matches the spec as given.
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. Follows the existing house style: pure/unit-tested logic in `bcc_core.py`. ## What's in `bcc_core.py` - `load_catalog(raw)` -- strict `json.loads` ONLY. The lenient repair pipeline (`repair_json_text` / `parse_pasted_json*`) is never used on catalog bytes, by design and by an explicit comment warning future editors not to wire it in: a signature only means something if it authenticates the exact bytes that get parsed. - `validate_catalog(data) -> list[str]` -- rejects the **whole file** (not just the bad entry) on: missing/malformed `schema`/`version`/`servers`; missing tier-appropriate fields (basic needs `config.command`+`args`; link-only needs `docs_url` and must NOT have `config`); a command allowlist (`npx`/`uvx`/`docker`/`node`/`python`/`python3` only); `-e`/`--eval`/`-c` denial for node/python; `--privileged` and `/`- or `$HOME`-rooted volume-mount denial for docker; **non-empty `env_required` values as a hard rejection** (never a warning -- secrets never ship in the catalog); secret-looking args (reuses the existing `_TOKEN_PREFIXES`/`_is_secret_value` rather than reimplementing); non-`https://` URL fields (`homepage`/`docs_url`/`source`); and non-ASCII code points in `id`/`command`/args (homoglyph/typosquat defence). - `catalog_version(data) -> int` - `verify_catalog_signature(raw, sig, pubkeys) -> bool` -- Ed25519 via the `cryptography` package (added to `pyproject.toml`). Domain-separated: the signed message is the literal prefix `"bcc-catalog-v1|"` + raw bytes, not raw alone. Returns True if ANY key in `pubkeys` verifies (rotation-ready). Never raises. - `CATALOG_PUBKEYS: list[bytes]` -- module constant seeded with a placeholder + `# TODO: real key from Catalog Console (#62)`; tests generate their own keypairs. - `resolve_catalog(bundled, cached, remote) -> dict` -- picks the highest version among candidates that **each independently** pass verify + validate. The bundled catalog gets **no implicit trust** -- it's verified at runtime exactly like remote/cached, closing the hole where an unsigned payload merged to main would win the version comparison just by being local. Anti-rollback (never regress below the best verified candidate already in hand this pass) and anti-freeze (reject a jump of more than 1000 versions) are both enforced. - `catalog_entry_to_paste_json(entry) -> dict` -- basic-tier entry to `{name: {command, args, env}}`, feeding the existing `_import_server` path. - `config_has_unfilled_placeholders(cfg) -> bool` -- true if any `<PLACEHOLDER>` token remains, for the future GUI to gate Save on. ## Data - `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). - `bcc.spec`'s `datas` now bundles `data/catalog.json` into the frozen app (same mechanism #20 added for icons). ## Security requirements from the issue -- where they landed - Catalog bytes never touch the lenient JSON repair path -- enforced by `load_catalog()`'s strict `json.loads` plus a comment warning against wiring it in later. ✅ - `env_required` non-empty values are a hard rejection, not a warning. ✅ - Secret-looking args rejected at validation time, reusing existing secret-detection helpers instead of duplicating them. ✅ - Non-ASCII `id`/`command`/args rejected (typosquat/homoglyph defence). ✅ - URL fields restricted to `https://`. ✅ - Ed25519 verification is domain-separated (`bcc-catalog-v1|` prefix) 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 (1000-version cap) bounds on `resolve_catalog`'s version comparison. ✅ - Network fetch, on-disk cache, and the GUI dialog are explicitly **out of scope** for this PR (follow-ups). ## Tests 42 new tests added to `tests/test_core.py`, covering: valid round-trip; tampered-byte signature failure; wrong-key failure; garbage/empty/missing signature; each validator rule's rejection (disallowed command, `node -e`, `python3 -c`, `docker --privileged`, docker `/`+`$HOME` volume mounts, non-empty `env_required`, secret-looking arg incl. token-prefix, `http://`/`file://` URLs, non-ASCII id/command/arg, duplicate ids); malformed JSON handling; the real shipped `data/catalog.json` passing `validate_catalog()` (the regression test that matters most); and `resolve_catalog` preferring the highest verified version while rejecting an unsigned bundled catalog, a rolled-back version, and an absurd version jump. **Full suite: 239 passed, 1 pre-existing unrelated skip (Windows-only test). `ruff check` and `ruff format --check` both clean.** ## Deviations from the brief - `resolve_catalog`'s exact parameter shape wasn't fully specified beyond `(bundled, cached, remote) -> dict`. I made each argument either `None` or a `(raw_bytes, signature_bytes)` tuple, verifying+validating from raw bytes inside the function (using the module-level `CATALOG_PUBKEYS`), and return `{}` when nothing verifies -- this seemed like the most literal reading that still lets the function independently re-verify every candidate including `bundled`. - No other deviations; everything else matches the spec as given.
the_og added 1 commit 2026-07-12 17:33:25 -04:00
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
48904c7787
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.
the_og added 1 commit 2026-07-12 17:35:52 -04:00
catalog: pin exact package versions, drop firecrawl, add last_release
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 16s
CI / Lint (ruff) (pull_request) Successful in 52s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 14s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 14s
88e93edc6c
- Pin every basic-tier entry to an exact published version (npm @x.y.z,
  uvx @x.y.z, docker :tag). Unpinned npx -y <pkg> means a package
  compromised AFTER we ship auto-upgrades into every user; a pin bounds
  supply-chain compromise to versions we actually reviewed.
- Drop firecrawl from the seed (19 entries). npm publish rights are held
  solely by hello_sideguide/sideguide.dev, which has no visible
  relationship to firecrawl.dev, while the package is presented as
  official. Publisher identity we cannot tie to the vendor is exactly
  what this catalog must not execute on a user's machine. Retained in the
  research pool pending confirmation.
- postgres: ship --access-mode=restricted, not unrestricted. A curated
  catalog must not default to handing an LLM write access to your DB.
- Add last_release (ISO date, from the live registry) so the UI can show
  freshness; postgres-mcp and mcp-obsidian are both ~14mo stale.
the_og added 1 commit 2026-07-12 17:42:14 -04:00
Merge branch 'main' into feat/10-catalog-core
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 43s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
e3581b6e8b
the_og merged commit 3841106630 into main 2026-07-12 17:55:49 -04:00
the_og deleted branch feat/10-catalog-core 2026-07-12 17:55:49 -04:00
Sign in to join this conversation.