fix(mcp): the default mode disabled the unified hanzo tool

0.15.13 shipped the 166-service surface switched off. `tools/list` against a
clean install returned 40 tools with no `hanzo`, and the server logged
"unified `hanzo` tool unavailable — keeping per-service tools enabled".

The import was fine; the mode gate was not. register_all_tools() asks
is_tool_enabled("hanzo", True), which returns tool_config["hanzo"] whenever the
key exists rather than falling back to the default. ModeLoader builds that
config by disabling every TOOL_REGISTRY key and re-enabling only the active
mode's tools. TOOL_REGISTRY has "hanzo"; the default personality's list did
not — so the "True" default was never reached and the tool was explicitly off.

Two things broke together, because the per-service cloud tools are retired only
once `hanzo` is enabled: the unified surface was absent AND the ten tools it
replaces were still mounted. The exact state 929e103e set out to end.

`hanzo` belongs in ESSENTIAL_TOOLS. It is an axis like fs or git — the single
seam to the platform — not a mode-specific extra. It stays out of
ESSENTIAL_SYSTEM_TOOLS so it remains disableable.

Verified in a clean uv venv from built wheels only (no editable, no PYTHONPATH):
tools/list now returns 31 tools including `hanzo`, and api/auth/billing/commerce/
iam/ingress/kms/mpc/paas/team are correctly gone (40 - 10 + 1). The tool answers
hanzo(service="services") with 166 services read live from
https://api.hanzo.ai/v1/openapi.json.

A test now pins the gate where it is actually decided. It fails against the
installed 0.15.13 (3 of 4) and passes against 0.15.14 — an import-level check
cannot catch this, because the import succeeds.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-27 21:08:44 -07:00
co-authored by Hanzo Dev
parent c64a22d41e
commit 580f1f6773
3 changed files with 88 additions and 1 deletions
@@ -116,6 +116,12 @@ ESSENTIAL_TOOLS = [
"critic",
# Agent spawning
"agent",
# Hanzo cloud — one tool projecting every service from the OpenAPI registry.
# It is an axis like the rest: the single seam to the platform, and the tool
# the per-service cloud tools are retired in favour of. A mode omitting it
# does not merely lose `hanzo`, it un-retires those ten legacy tools, because
# register_all_tools() only hides them once `hanzo` is enabled.
"hanzo",
# System / configuration
"config",
"mode",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-mcp"
version = "0.15.13"
version = "0.15.14"
description = "The Zen of Hanzo MCP: One server to rule them all. The ultimate MCP that orchestrates all others."
readme = "README.md"
requires-python = ">=3.12"
@@ -0,0 +1,81 @@
"""Regression: the default mode must leave the unified `hanzo` tool enabled.
The bug this guards against: ``register_all_tools`` gates the unified tool on
``is_tool_enabled("hanzo", True)``, which returns ``tool_config["hanzo"]``
whenever that key exists. ``ModeLoader.get_enabled_tools_from_mode`` builds
``tool_config`` by disabling every key in ``TOOL_REGISTRY`` and then re-enabling
only the active mode's ``tools`` list. ``TOOL_REGISTRY`` has ``"hanzo"``, so a
mode that omits it does not fall back to the ``True`` default — it explicitly
disables it.
That is what shipped in 0.15.13: the tool built, imported, and registered its
entry point, yet ``tools/list`` came back with 40 tools and no ``hanzo``, while
the server logged "unified `hanzo` tool unavailable". Two things broke at once,
because ``register_all_tools`` only retires the per-service cloud tools once
``hanzo`` is enabled — so the 166-service surface was off AND the ten tools it
replaces were still on. An import-level check cannot catch this: the import
succeeds. The gate has to be tested where it is actually decided.
"""
import pytest
from hanzo_mcp.config.tool_config import TOOL_REGISTRY
from hanzo_mcp.tools import SUPERSEDED_BY_HANZO, _unified_hanzo_available
from hanzo_mcp.tools.common.mode import ModeRegistry, activate_mode_from_env
from hanzo_mcp.tools.common.mode_loader import ModeLoader
UNIFIED = "hanzo"
@pytest.fixture(autouse=True)
def _modes_loaded():
"""Modes are registered lazily; the gate reads whatever is active."""
ModeLoader.initialize_modes()
activate_mode_from_env()
def test_unified_tool_is_in_the_registry():
"""If it left TOOL_REGISTRY the clean-slate loop could no longer reach it."""
assert UNIFIED in TOOL_REGISTRY
def test_default_mode_lists_the_unified_tool():
"""The active mode must name `hanzo`, or the clean slate leaves it off."""
mode = ModeRegistry.get_active()
assert mode is not None, "no active mode; the gate would read an empty config"
assert UNIFIED in mode.tools, (
f"mode {mode.name!r} omits {UNIFIED!r}, so ModeLoader disables it. "
f"mode.tools={sorted(mode.tools)}"
)
def test_resolved_config_enables_the_unified_tool():
"""The fingerprint of the 0.15.13 regression: tool_config['hanzo'] is False."""
config = ModeLoader.get_enabled_tools_from_mode(
base_enabled_tools=None, force_mode=None
)
assert config.get(UNIFIED) is True, (
f"tool_config[{UNIFIED!r}]={config.get(UNIFIED)!r} — register_all_tools "
"will skip the unified tool and keep the per-service tools it replaces."
)
def test_superseded_tools_are_retired_when_the_unified_tool_is_available():
"""Enabling `hanzo` is what retires the ten per-service cloud tools.
Mirrors the branch in register_all_tools so the two cannot drift: if the
unified tool imports and the mode enables it, none of the tools it replaces
should survive into the resolved config.
"""
if not _unified_hanzo_available():
pytest.skip("hanzo-tools-api not installed; nothing to supersede")
config = ModeLoader.get_enabled_tools_from_mode(
base_enabled_tools=None, force_mode=None
)
assert config.get(UNIFIED) is True
still_on = [t for t in SUPERSEDED_BY_HANZO if config.get(t)]
assert not still_on, (
f"{still_on} stayed enabled alongside the unified tool that replaces them"
)