From 385efa70c21698e332f6780e77beb67627433ace Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:18:41 -0700 Subject: [PATCH 1/3] feat(mcp): add Blender to the MCP catalog with a curated 4-tool default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional-mcps/blender (ahujasid/blender-mcp, stdio via uvx). The server advertises 22 tools; 18 front optional asset services with no upstream trim mechanism, so tools.default_enabled pins the install to the core surface (scene/object info, viewport screenshot, code exec) and the rest stay opt-in through 'hermes mcp configure blender'. Manifests can now declare transport.env (static, non-secret subprocess env vars), parsed/validated in _parse_manifest and written by _build_server_config — used here to ship DISABLE_TELEMETRY=true per the no-telemetry-without-opt-in policy. Runtime already honored per-server env; manifests just couldn't declare it. --- hermes_cli/mcp_catalog.py | 14 +++++ optional-mcps/blender/manifest.yaml | 83 ++++++++++++++++++++++++++++ tests/hermes_cli/test_mcp_catalog.py | 26 +++++++++ 3 files changed, 123 insertions(+) create mode 100644 optional-mcps/blender/manifest.yaml diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index aab35394964a..a75a0d2ca6e9 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -77,6 +77,10 @@ class TransportSpec: args: List[str] = field(default_factory=list) url: Optional[str] = None version: Optional[str] = None # informational, pinned + # Static environment variables for the stdio subprocess (e.g. telemetry + # opt-outs, mode flags). NOT for secrets — credentials go through + # auth.env so they are prompted for and land in ~/.hermes/.env. + env: Dict[str, str] = field(default_factory=dict) @dataclass @@ -184,12 +188,20 @@ def _parse_manifest(path: Path) -> CatalogEntry: args = transport_raw.get("args") or [] if not isinstance(args, list): raise CatalogError(f"{path}: transport.args must be a list") + env_raw = transport_raw.get("env") or {} + if not isinstance(env_raw, dict) or not all( + isinstance(k, str) and isinstance(v, str) for k, v in env_raw.items() + ): + raise CatalogError( + f"{path}: transport.env must be a mapping of string to string" + ) transport = TransportSpec( type=t_type, command=transport_raw.get("command"), args=[str(a) for a in args], url=transport_raw.get("url"), version=transport_raw.get("version"), + env=dict(env_raw), ) if t_type == "stdio" and not transport.command: raise CatalogError(f"{path}: stdio transport requires 'command'") @@ -468,6 +480,8 @@ def _build_server_config( cfg["command"] = _expand_install_dir(t.command or "", install_dir) if t.args: cfg["args"] = [_expand_install_dir(a, install_dir) for a in t.args] + if t.env: + cfg["env"] = dict(t.env) elif t.type == "http": cfg["url"] = t.url if entry.auth.type == "oauth": diff --git a/optional-mcps/blender/manifest.yaml b/optional-mcps/blender/manifest.yaml new file mode 100644 index 000000000000..e897a0778b5a --- /dev/null +++ b/optional-mcps/blender/manifest.yaml @@ -0,0 +1,83 @@ +# Nous-approved MCP catalog entry. +# Presence in this directory = approval. Merged via PR review. +manifest_version: 1 + +name: blender +description: Drive a live Blender session — modeling, scenes, and renders. +source: https://github.com/ahujasid/blender-mcp + +# ahujasid/blender-mcp (MIT, PyPI: blender-mcp) is the de-facto standard +# Blender MCP. It is a bridge with two halves: +# 1. This stdio server (uvx blender-mcp), which Hermes launches. +# 2. An addon (addon.py) running inside Blender that opens a local command +# socket on 127.0.0.1:9876. The stdio server relays to it. +# There is nothing to git-clone on the Hermes side — uvx resolves the package — +# so no install block. The in-Blender addon setup is covered in post_install. +# +# Why not Blender's official MCP (blender.org/lab): it requires Blender 5.1+ +# and targets scene analysis/documentation rather than asset creation. This +# server works across Blender 3.x/4.x and exposes full modeling control. +transport: + type: stdio + command: "uvx" + args: + - "blender-mcp" + env: + # Upstream ships anonymous telemetry, on by default. Hermes policy is + # no outbound telemetry without explicit opt-in, so the catalog entry + # disables it. Remove this line only if you deliberately want it on. + DISABLE_TELEMETRY: "true" + +# The Blender addon binds 127.0.0.1 only and has no auth of its own; the +# stdio server needs no credentials. Optional integrations (Sketchfab, +# Hyper3D Rodin, Hunyuan3D) take API keys entered in the addon's N-panel +# inside Blender, not via Hermes env. +auth: + type: none + +# Tool selection at install time: +# The server advertises 22 tools. 18 of them front optional third-party asset +# services (PolyHaven, Sketchfab, Hyper3D Rodin, Hunyuan3D) that are dead +# weight unless the matching integration is enabled in the addon panel — and +# upstream has no server-side trim mechanism, so without a filter every one +# of them costs schema tokens on every API call. Default to the core surface: +# scene/object inspection, viewport screenshots, and code execution, which is +# the complete modeling/render capability. Users who enable an asset service +# in the addon can opt into its tools with `hermes mcp configure blender`. +tools: + default_enabled: + - get_scene_info + - get_object_info + - get_viewport_screenshot + - execute_blender_code + +post_install: | + This entry launches the bridge server, but the bridge talks to an addon + INSIDE a running Blender (3.0+). One-time setup: + + 1. Download addon.py from https://github.com/ahujasid/blender-mcp + (raw file: https://raw.githubusercontent.com/ahujasid/blender-mcp/main/addon.py) + 2. Blender > Edit > Preferences > Add-ons > Install... > select addon.py, + then enable "Interface: Blender MCP". + 3. In the 3D viewport press N, open the "BlenderMCP" tab, click + "Connect to Claude" (starts the local socket on 127.0.0.1:9876). + + Blender must be RUNNING with the addon connected before the tools work — + start Blender first, then your Hermes session. The addon refuses to start + under `blender -b` (background mode): its command queue runs on UI timers. + On a machine without a display, run Blender under a virtual one: + xvfb-run blender (or Xvfb :99 & DISPLAY=:99 blender) + GPU rendering (Cycles/OptiX) works fine under Xvfb. + + SECURITY: execute_blender_code runs arbitrary Python inside Blender with no + sandbox — same trust level as the terminal tool. Upstream telemetry is + disabled via DISABLE_TELEMETRY in this entry's env block. + + The 18 asset-service tools (PolyHaven/Sketchfab/Hyper3D/Hunyuan3D) are off + by default. To use one: enable the service in the addon's BlenderMCP panel + (API key there if required), then `hermes mcp configure blender` and tick + its tools. PolyHaven is free and keyless; the others need accounts. + + If Hermes and Blender run on different machines, note that file paths in + execute_blender_code (texture loads, exports) resolve on the BLENDER host's + filesystem, not where Hermes runs. diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py index b86cb5ea14e2..a1679e38f5b9 100644 --- a/tests/hermes_cli/test_mcp_catalog.py +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -197,6 +197,32 @@ def test_get_entry_strips_official_prefix(self, catalog_dir): assert get_entry("official/demo") is not None assert get_entry("missing") is None + def test_transport_env_parsed_and_written_to_server_config(self, catalog_dir): + body = _basic_manifest() + body["transport"]["env"] = {"DISABLE_TELEMETRY": "true"} + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import _build_server_config + + e = _entry("demo") + assert e.transport.env == {"DISABLE_TELEMETRY": "true"} + cfg = _build_server_config(e, None) + assert cfg["env"] == {"DISABLE_TELEMETRY": "true"} + + def test_transport_env_absent_leaves_config_without_env_key(self, catalog_dir): + _write_manifest(catalog_dir, "demo", _basic_manifest()) + from hermes_cli.mcp_catalog import _build_server_config + + cfg = _build_server_config(_entry("demo"), None) + assert "env" not in cfg + + def test_transport_env_bad_shape_rejected(self, catalog_dir): + body = _basic_manifest() + body["transport"]["env"] = ["DISABLE_TELEMETRY=true"] # list, not mapping + _write_manifest(catalog_dir, "demo", body) + from hermes_cli.mcp_catalog import list_catalog + + assert list_catalog() == [] + # --------------------------------------------------------------------------- # Install flow From e63cce6dd0ba0d604f1d31d4d49b46223a0a37bc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:59:20 -0700 Subject: [PATCH 2/3] fix(mcp): pin blender-mcp to 1.6.4 per catalog dependency policy MCP catalog entries follow the same supply-chain rules as pyproject dependencies: exact version pin, and the pinned release must be at least 2 weeks old. blender-mcp 1.6.4 released 2026-06-11 (~5 weeks old, also the latest release). uvx now resolves the exact version instead of latest-at-launch. --- optional-mcps/blender/manifest.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/optional-mcps/blender/manifest.yaml b/optional-mcps/blender/manifest.yaml index e897a0778b5a..ad170d0e336b 100644 --- a/optional-mcps/blender/manifest.yaml +++ b/optional-mcps/blender/manifest.yaml @@ -20,8 +20,13 @@ source: https://github.com/ahujasid/blender-mcp transport: type: stdio command: "uvx" + # Pinned per catalog dependency policy: exact version, and the release must + # be at least 2 weeks old at pin time (supply-chain cooldown — same rule as + # pyproject.toml dependencies). 1.6.4 released 2026-06-11. The catalog never + # auto-updates; bumping the pin is a PR to this manifest. args: - - "blender-mcp" + - "blender-mcp==1.6.4" + version: "1.6.4" env: # Upstream ships anonymous telemetry, on by default. Hermes policy is # no outbound telemetry without explicit opt-in, so the catalog entry From bd08e9d164e502a1277ff9229aec9304e71a9560 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 3/3] 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 @@ - 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 @@ from __future__ import annotations +import re from pathlib import Path from unittest.mock import patch @@ -838,3 +839,59 @@ def test_all_shipped_manifests_parse(self, monkeypatch): 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)