Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion hermes_cli/mcp_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`` to
pull a new manifest version after a repo update.
- Secrets prompted at install time go to ``~/.hermes/.env`` (the
Expand Down Expand Up @@ -77,6 +81,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
Expand Down Expand Up @@ -184,12 +192,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'")
Expand Down Expand Up @@ -468,6 +484,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":
Expand Down
88 changes: 88 additions & 0 deletions optional-mcps/blender/manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# 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"
# 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==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
# 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.
8 changes: 6 additions & 2 deletions optional-mcps/n8n/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
83 changes: 83 additions & 0 deletions tests/hermes_cli/test_mcp_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import re
from pathlib import Path
from unittest.mock import patch

Expand Down Expand Up @@ -197,6 +198,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
Expand Down Expand Up @@ -812,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)
Loading