Renames the package from `mcp-cucm-axl` to `mcaxl` to fit the
operator's mc<interface> naming convention (mcusb, mcaxl, …),
and scrubs Bingham-specific defaults so the package works for
anyone, anywhere.
Rename:
- pyproject.toml: name, scripts entry point, description
- src/mcp_cucm_axl/ → src/mcaxl/ (git mv preserves history)
- All Python imports updated via sed
- Cache directory: ~/.cache/mcp-cucm-axl/ → ~/.cache/mcaxl/
- Log prefix [mcp-cucm-axl] → [mcaxl]
- Package version lookup: importlib.metadata.version("mcaxl")
- .mcp.json command updated to invoke `mcaxl` script
- All 155 tests pass under the new name (verified)
Bingham-specific scrubs:
- docs_loader._DEFAULT_INDEX_DIR: hardcoded /home/rpm/bingham/...
path removed; defaults to None. Operators set CISCO_DOCS_INDEX_PATH
env var; without it, prompts gracefully degrade with a fallback
notice instructing the LLM to use the cisco-docs MCP search_docs
tool instead.
- prompts/_common.docs_or_empty_msg: removed the explicit
/home/rpm/bingham/... path from the fallback message text.
- server.py: removed dead-code copy of _docs_or_empty_msg() that
was leftover from before the prompts package extraction.
- README.md: completely rewritten as a public-facing readme. Lead
paragraph names CUCM as the target platform, install instructions
cover uvx / pip / Claude Code MCP add. Recommends cisco-cucm-mcp
as the operations counterpart.
PyPI metadata:
- Initial CalVer version: 2026.04.27
- License: MIT (LICENSE file added)
- Project URLs: Homepage / Source / Issues / Changelog all point
at git.supported.systems/mcp/mcaxl (newly-created Gitea repo
in the mcp/ org for PyPI releases)
- Classifiers: Beta / Telecommunications Industry / Topic:Telephony
- Keywords: mcp, cisco, cucm, axl, risport, voip, sip, audit
- sdist excludes: CLAUDE.md, .env*, axlsqltoolkit.zip, audits/,
tests/, pytest/ruff caches. Verified clean: wheel ships only the
mcaxl/ source tree + LICENSE + METADATA + entry_points.
CHANGELOG.md added with a 2026.04.27 initial-release entry,
documenting tool/prompt counts, structural read-only guarantees,
Hamilton review closure, live-cluster verification, and known
limitations.
Build verification:
- `uv build` produces clean wheel + sdist
- Wheel: 22 source files, 195KB total, no Bingham-specific files
- Sdist excludes verified: no CLAUDE.md, no axlsqltoolkit.zip
- Entry point: `mcaxl = mcaxl.server:main`
- Package installs as mcaxl==2026.4.27
156 lines
6.2 KiB
Python
156 lines
6.2 KiB
Python
"""Hamilton review MAJOR #5: connection recovery and config-vs-operational errors.
|
|
|
|
Pre-fix: any connection failure set `_connection_error` and pinned it forever.
|
|
A transient network blip required restarting the MCP server. Fix: distinguish
|
|
*configuration* errors (missing env, bad WSDL) which are pinned, from
|
|
*operational* errors (network, TLS, session timeout) which can be retried
|
|
on the next call.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from mcaxl.cache import AxlCache
|
|
from mcaxl.client import AxlClient
|
|
|
|
|
|
@pytest.fixture
|
|
def cache(tmp_path: Path) -> AxlCache:
|
|
return AxlCache(tmp_path / "test.sqlite", default_ttl=60, cluster_id="test")
|
|
|
|
|
|
def test_config_error_is_pinned(cache: AxlCache, monkeypatch):
|
|
"""Missing AXL_URL is a config error — it doesn't get better on retry,
|
|
and the next call should still raise the same clear message."""
|
|
monkeypatch.delenv("AXL_URL", raising=False)
|
|
monkeypatch.delenv("AXL_USER", raising=False)
|
|
monkeypatch.delenv("AXL_PASS", raising=False)
|
|
client = AxlClient(cache)
|
|
|
|
with pytest.raises(RuntimeError, match="AXL_URL"):
|
|
client._ensure_connected()
|
|
# Second call: same config error, pinned
|
|
with pytest.raises(RuntimeError, match="AXL_URL"):
|
|
client._ensure_connected()
|
|
|
|
|
|
def test_operational_error_is_not_pinned(cache: AxlCache, monkeypatch):
|
|
"""A transient operational error (zeep Client construction failing,
|
|
network blip, etc.) should NOT pin the client forever. The next call
|
|
must be allowed to retry."""
|
|
monkeypatch.setenv("AXL_URL", "https://test.invalid:8443/axl")
|
|
monkeypatch.setenv("AXL_USER", "test")
|
|
monkeypatch.setenv("AXL_PASS", "test")
|
|
monkeypatch.setenv("AXL_VERIFY_TLS", "false")
|
|
|
|
# Force the zeep Client constructor inside _ensure_connected to raise.
|
|
# This simulates "WSDL fetch failed", "TLS handshake error", etc. —
|
|
# transient operational failures.
|
|
from mcaxl import client as client_mod
|
|
|
|
def boom(*args, **kwargs):
|
|
raise ConnectionError("simulated transient network failure")
|
|
|
|
monkeypatch.setattr(client_mod, "Client", boom)
|
|
|
|
client = AxlClient(cache)
|
|
with pytest.raises(RuntimeError, match="simulated transient"):
|
|
client._ensure_connected()
|
|
|
|
# Hamilton review MAJOR #5: operational errors must NOT set _config_error.
|
|
# _config_error is the permanent pin; only set on missing env vars / config
|
|
# mistakes. A failed network connection is operational and the next call
|
|
# must be allowed to retry.
|
|
assert client._config_error is None, (
|
|
"operational errors must not set _config_error (the pin); "
|
|
"only configuration errors (missing env vars, bad WSDL) should pin"
|
|
)
|
|
# _last_error is set for diagnostics, but it does not block retries.
|
|
assert client._last_error is not None, (
|
|
"_last_error should record the operational failure for diagnostics"
|
|
)
|
|
assert "simulated transient" in client._last_error
|
|
|
|
|
|
def test_health_diagnostic_includes_connection_state(cache: AxlCache):
|
|
"""The client should expose its connection age / last-attempt info
|
|
so an operator can see what's going on without reading sys.stderr."""
|
|
client = AxlClient(cache)
|
|
info = client.connection_status()
|
|
assert "connected" in info
|
|
assert info["connected"] is False # never tried yet
|
|
assert "last_error" in info
|
|
|
|
|
|
# ---- Rate limit / 503 retry --------------------------------------------------
|
|
# Inspired by cisco-cucm-mcp's exponential-backoff approach. CUCM's SOAP
|
|
# layer returns 503 under load (concurrent AXL admins, change window). Without
|
|
# retries, we'd fail loudly; with them, transient rate limiting becomes
|
|
# invisible to the caller.
|
|
|
|
def test_retry_config_default_three_retries(cache: AxlCache, monkeypatch):
|
|
"""By default, the session is configured for 3 retries with backoff."""
|
|
monkeypatch.setenv("AXL_URL", "https://example.invalid:8443/axl")
|
|
monkeypatch.setenv("AXL_USER", "test")
|
|
monkeypatch.setenv("AXL_PASS", "test")
|
|
monkeypatch.setenv("AXL_VERIFY_TLS", "false")
|
|
# Stub Client construction so we exercise only the session/retry setup
|
|
from mcaxl import client as client_mod
|
|
|
|
constructed = {}
|
|
|
|
def stub_client(*args, **kwargs):
|
|
constructed["transport"] = kwargs.get("transport")
|
|
# Raise to short-circuit before service creation
|
|
raise ConnectionError("stub: don't actually connect")
|
|
|
|
monkeypatch.setattr(client_mod, "Client", stub_client)
|
|
|
|
client = AxlClient(cache)
|
|
with pytest.raises(RuntimeError):
|
|
client._ensure_connected()
|
|
|
|
info = client.connection_status()
|
|
assert info["retry_config"] is not None
|
|
assert info["retry_config"]["max_retries"] == 3
|
|
assert 503 in info["retry_config"]["status_forcelist"]
|
|
assert 502 in info["retry_config"]["status_forcelist"]
|
|
assert 504 in info["retry_config"]["status_forcelist"]
|
|
|
|
|
|
def test_retry_config_overridable_via_env(cache: AxlCache, monkeypatch):
|
|
"""Operators can tune the retry count via AXL_RATE_LIMIT_RETRIES."""
|
|
monkeypatch.setenv("AXL_URL", "https://example.invalid:8443/axl")
|
|
monkeypatch.setenv("AXL_USER", "test")
|
|
monkeypatch.setenv("AXL_PASS", "test")
|
|
monkeypatch.setenv("AXL_RATE_LIMIT_RETRIES", "7")
|
|
|
|
from mcaxl import client as client_mod
|
|
monkeypatch.setattr(client_mod, "Client", lambda *a, **kw: (_ for _ in ()).throw(ConnectionError("stub")))
|
|
|
|
client = AxlClient(cache)
|
|
with pytest.raises(RuntimeError):
|
|
client._ensure_connected()
|
|
|
|
assert client.connection_status()["retry_config"]["max_retries"] == 7
|
|
|
|
|
|
def test_retry_config_zero_disables(cache: AxlCache, monkeypatch):
|
|
"""AXL_RATE_LIMIT_RETRIES=0 disables the retry adapter entirely.
|
|
Useful for test environments or when an operator wants raw failures."""
|
|
monkeypatch.setenv("AXL_URL", "https://example.invalid:8443/axl")
|
|
monkeypatch.setenv("AXL_USER", "test")
|
|
monkeypatch.setenv("AXL_PASS", "test")
|
|
monkeypatch.setenv("AXL_RATE_LIMIT_RETRIES", "0")
|
|
|
|
from mcaxl import client as client_mod
|
|
monkeypatch.setattr(client_mod, "Client", lambda *a, **kw: (_ for _ in ()).throw(ConnectionError("stub")))
|
|
|
|
client = AxlClient(cache)
|
|
with pytest.raises(RuntimeError):
|
|
client._ensure_connected()
|
|
|
|
cfg = client.connection_status()["retry_config"]
|
|
assert cfg["max_retries"] == 0
|