From 39b76d90137a86bc953f340acaf8ac038545c612 Mon Sep 17 00:00:00 2001 From: Frowte3k <164990034+Frowtek@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:03:20 +0300 Subject: [PATCH] fix(packaging): ship optional-mcps catalog in wheel and sdist (#39859) The shipped MCP catalog (optional-mcps/) wasn't packaged, so `hermes mcp catalog` and the dashboard catalog screen come up empty on pip/Homebrew/Nix installs even though the manifests exist in the repo. The runtime expects a packaged catalog (get_optional_mcps_dir() -> _get_packaged_data_dir("optional-mcps"); list_catalog() returns [] when it's absent). Ship it like locales: pyproject [tool.setuptools.data-files] for the wheel + a MANIFEST.in graft for the sdist. optional-mcps/ is nested (optional-mcps//manifest.yaml) and data-files flattens each glob into its target dir, so each catalog entry gets its own target to preserve the per-entry directory the catalog iterates over. --- MANIFEST.in | 1 + pyproject.toml | 14 ++++++++++++ tests/test_packaging_metadata.py | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index a6749adc2cf..5d5a1b1b271 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ graft skills graft optional-skills +graft optional-mcps graft locales # Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the # PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs diff --git a/pyproject.toml b/pyproject.toml index 37aaeacad0a..fc143a12bed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -285,6 +285,20 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector # venv) drop the catalogs and gateway/CLI commands surface raw i18n keys like # `gateway.reset.header_default` (#27632, #35374, #23943). locales = ["locales/*.yaml"] +# Shipped MCP catalog (optional-mcps//manifest.yaml). Same bare-data-dir +# case as locales: data-files ships it in the wheel, `graft optional-mcps` in +# MANIFEST.in ships it in the sdist. Without this, `hermes mcp catalog` and the +# dashboard catalog screen come up empty on packaged installs even though the +# manifests exist in the repo (hermes_cli/mcp_catalog.py:_catalog_root resolves +# the packaged dir; list_catalog() returns [] when it's missing). +# +# data-files flattens every glob match into its single target dir, so each +# catalog entry needs its OWN target to preserve the per-entry directory the +# catalog iterates over (a shared `optional-mcps/*/*` glob would collapse all +# manifests into one colliding optional-mcps/manifest.yaml). One target per +# entry; tests/test_packaging_metadata.py enforces an entry per optional-mcps/. +"optional-mcps/linear" = ["optional-mcps/linear/manifest.yaml"] +"optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"] [tool.setuptools.package-data] hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"] diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index edc1cb6d1b3..4a8a7add5a3 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -264,3 +264,40 @@ def test_locale_catalogs_ship_in_both_wheel_and_sdist(): # Every on-disk catalog has the .yaml extension the globs above match. on_disk = list((REPO_ROOT / "locales").glob("*.yaml")) assert on_disk, "expected locales/*.yaml catalogs on disk" + + +def test_optional_mcps_manifests_ship_in_both_wheel_and_sdist(): + """Regression guard: the shipped MCP catalog must reach packaged installs. + + hermes_cli/mcp_catalog.py resolves the catalog via get_optional_mcps_dir() + -> _get_packaged_data_dir("optional-mcps"), and list_catalog() returns [] + when that directory is absent. optional-mcps/ is a bare data directory (no + __init__.py), invisible to packages.find and package-data. It must ship as + setuptools data-files (wheel) AND be grafted in MANIFEST.in (sdist), or + `hermes mcp catalog` and the dashboard catalog screen come up empty on + pip / Homebrew / Nix installs even though the manifests exist in the repo. + + data-files flattens every glob match into its single target dir, so each + catalog entry needs its OWN target to preserve the optional-mcps// + directory the catalog iterates over. This asserts one target per on-disk + entry so a newly-added MCP can't silently miss the wheel. + """ + entries = sorted( + p.parent.name for p in (REPO_ROOT / "optional-mcps").glob("*/manifest.yaml") + ) + assert entries, "expected optional-mcps//manifest.yaml on disk" + + data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + data_files = data["tool"]["setuptools"].get("data-files", {}) + for name in entries: + target = f"optional-mcps/{name}" + assert target in data_files, ( + f"pyproject [tool.setuptools.data-files] must declare a '{target}' " + f"target so the wheel ships optional-mcps/{name}/manifest.yaml " + f"(data-files flattens globs, so each catalog entry needs its own target)" + ) + + manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8") + assert "graft optional-mcps" in manifest, ( + "MANIFEST.in must `graft optional-mcps` so the sdist ships MCP manifests" + )