mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
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:
parent
a52393a3b6
commit
9df5f879b4
3 changed files with 68 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue