From 9df5f879b4a5925c0f8f947e7e16ed8e845932c3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:43:30 -0700 Subject: [PATCH] feat(mcp): enforce exact version pins across the whole MCP catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/mcp_catalog.py | 6 ++- optional-mcps/n8n/manifest.yaml | 8 +++- tests/hermes_cli/test_mcp_catalog.py | 57 ++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index a75a0d2ca6e9..f2a5303abe94 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -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 `` to pull a new manifest version after a repo update. - Secrets prompted at install time go to ``~/.hermes/.env`` (the diff --git a/optional-mcps/n8n/manifest.yaml b/optional-mcps/n8n/manifest.yaml index 468efd1ddafb..bc2fcb5b3093 100644 --- a/optional-mcps/n8n/manifest.yaml +++ b/optional-mcps/n8n/manifest.yaml @@ -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" diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py index a1679e38f5b9..0c440f4909bc 100644 --- a/tests/hermes_cli/test_mcp_catalog.py +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -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)