mcaxl/tests/test_client_recovery.py
Ryan Malloy ca6956e826 Rename to mcaxl + scrub for public PyPI release
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
2026-04-27 12:53:54 -06:00

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