feat(mcp): enforce exact version pins across the whole MCP catalog

Catalog entries now follow the same supply-chain rules as pyproject
dependencies:

- n8n: install.ref main -> full commit SHA 7a9ae007 (2026-05-23,
  branches/tags can be moved by the upstream owner; SHAs cannot)
- new contract test: every shipped manifest must pin exactly —
  git installs need a 40-char SHA, uvx/npx-style launchers need
  pkg==X / pkg@X with a digit-leading version (rejects bare names,
  ranges, and npm dist-tags like @latest)
- module docstring documents the pin policy (exact version, 2-week
  cooldown)

unreal-engine and linear are http transports (server runs elsewhere)
so there is nothing to pin at the transport layer.

Verified: unpinning blender-mcp in the manifest makes the contract
test fail with a named diagnostic; restoring the pin passes.
This commit is contained in:
Teknium 2026-07-15 04:43:30 -07:00
parent a52393a3b6
commit 9df5f879b4
3 changed files with 68 additions and 3 deletions

View file

@ -10,7 +10,11 @@ Catalog policy:
- Entries are added only by merging a PR into hermes-agent. Presence in the
``optional-mcps/`` directory = Nous approval. No community tier, no trust
signals beyond "it's in the catalog".
- Manifests pin transport details (commands, args, refs). MCPs are never
- Manifests pin transport details (commands, args, refs). Pins follow the
same supply-chain rules as pyproject dependencies: exact versions for
package launchers (``uvx pkg==X``, ``npx pkg@X``), full commit SHAs for
git installs, and the pinned release should be at least 2 weeks old at
pin time. MCPs are never
auto-updated; users explicitly re-run ``hermes mcp install <name>`` to
pull a new manifest version after a repo update.
- Secrets prompted at install time go to ``~/.hermes/.env`` (the

View file

@ -27,8 +27,12 @@ transport:
install:
type: git
url: https://github.com/CyberSamuraiX/hermes-n8n-mcp.git
# Pin to a commit/tag. Required — manifests do not float HEAD.
ref: main
# Pinned per catalog dependency policy: full commit SHA (branches and tags
# can be moved; SHAs cannot), and the pinned commit must be at least
# 2 weeks old at pin time — same supply-chain rules as pyproject
# dependencies. This SHA is "feat: add local n8n MCP bridge for Hermes"
# (2026-05-23). Bumping the pin is a PR to this manifest.
ref: 7a9ae00795593aa1fdb4e61ecd640e8bfd0c3841
# Bootstrap commands run inside the cloned directory after clone.
bootstrap:
- "python3 -m venv .venv"

View file

@ -7,6 +7,7 @@ launch an MCP is mocked.
from __future__ import annotations
import re
from pathlib import Path
from unittest.mock import patch
@ -838,3 +839,59 @@ class TestShippedCatalog:
assert entry.name
assert entry.description
assert entry.transport.type in ("stdio", "http")
def test_all_shipped_manifests_are_version_locked(self, monkeypatch):
"""Contract: catalog entries follow the same supply-chain rules as
pyproject dependencies everything Hermes fetches/launches is pinned
to an exact version.
- git installs must pin a full 40-char commit SHA (branches and tags
can be moved by the upstream owner; SHAs cannot).
- package-launcher stdio transports (uvx/npx and their pkg-manager
equivalents) must carry an exact version specifier on the package
arg (``pkg==X`` for Python, ``pkg@X`` for npm).
http transports and ${INSTALL_DIR}-anchored commands have nothing to
pin at the transport layer (the server runs elsewhere / comes from the
SHA-pinned clone), so they're exempt.
"""
monkeypatch.delenv("HERMES_OPTIONAL_MCPS", raising=False)
from hermes_cli.mcp_catalog import _catalog_root, _parse_manifest
root = _catalog_root()
if not root.exists():
pytest.skip("optional-mcps/ not present in this checkout")
launcher_commands = {"uvx", "npx", "pipx", "bunx", "pnpx"}
problems = []
for m in root.glob("*/manifest.yaml"):
entry = _parse_manifest(m)
if entry.install is not None:
if not re.fullmatch(r"[0-9a-f]{40}", entry.install.ref):
problems.append(
f"{entry.name}: install.ref {entry.install.ref!r} is not "
"a full 40-char commit SHA"
)
t = entry.transport
if t.type == "stdio" and (t.command or "") in launcher_commands:
pkg_args = [a for a in t.args if not a.startswith("-")]
if not pkg_args:
problems.append(f"{entry.name}: launcher {t.command} has no package arg")
continue
pkg = pkg_args[0]
# Exact-pin shapes: pkg==1.2.3 (uvx/pipx) or pkg@1.2.3 /
# @scope/pkg@1.2.3 (npx/bunx/pnpx). The version must start
# with a digit — a bare name, a range operator, or an npm
# dist-tag (@latest, @next) floats and is rejected.
exact = re.fullmatch(r"[^=@\s]+==\d[\w.\-+]*", pkg) or re.fullmatch(
r"(@[\w.\-]+/)?[\w.\-]+@\d[\w.\-+]*", pkg
)
if not exact:
problems.append(
f"{entry.name}: package arg {pkg!r} is not pinned to an "
"exact version (expected pkg==X or pkg@X)"
)
assert not problems, "unpinned catalog entries:\n" + "\n".join(problems)