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
320 lines
14 KiB
Python
320 lines
14 KiB
Python
"""Hamilton review MAJOR #3: find_devices_using_css must surface partial failures.
|
|
|
|
The function is per-category resilient by design — if one schema query fails,
|
|
the others still produce results. But the top-level summary previously hid
|
|
that some categories errored out: `total_returned` and `any_truncated` only
|
|
reflected the SUCCESSFUL categories. An LLM consuming "47 references, low
|
|
impact" wouldn't know that 5 categories errored and the real number is
|
|
likely much higher.
|
|
|
|
After the fix: the response includes `complete: bool`, `categories_with_errors`,
|
|
and `error_categories`, so an LLM (or human auditor) can see the partial-failure
|
|
state and act on it.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from mcaxl.route_plan import find_devices_using_css
|
|
|
|
|
|
class FakeAxlClient:
|
|
"""Minimal stand-in for AxlClient that lets us simulate per-query failures.
|
|
|
|
Returns a fake CSS pkid for the lookup query, then either a single fake row
|
|
or an exception based on substring matching.
|
|
"""
|
|
|
|
def __init__(self, error_on_columns: list[str] | None = None):
|
|
self.error_on_columns = error_on_columns or []
|
|
self.queries: list[str] = []
|
|
|
|
def execute_sql_query(self, sql: str) -> dict:
|
|
self.queries.append(sql)
|
|
# The CSS lookup query — return a fake pkid
|
|
if "callingsearchspace WHERE name" in sql:
|
|
return {"row_count": 1, "rows": [{"pkid": "fake-css-pkid"}]}
|
|
# Any query referencing an "error trigger" column → simulate failure
|
|
for trigger in self.error_on_columns:
|
|
if trigger in sql:
|
|
raise RuntimeError(f"simulated cluster failure on {trigger}")
|
|
# Otherwise return one fake reference row so the category isn't empty
|
|
return {
|
|
"row_count": 1,
|
|
"rows": [{"name": "FakeRef", "context": "FakePart", "description": "fake"}],
|
|
}
|
|
|
|
|
|
def test_no_errors_reports_complete():
|
|
"""Baseline: when every category succeeds, complete=True and no error fields populated."""
|
|
client = FakeAxlClient()
|
|
result = find_devices_using_css(client, "Some-CSS")
|
|
assert result["complete"] is True
|
|
assert result["categories_with_errors"] == 0
|
|
assert result["error_categories"] == []
|
|
# And total_returned reflects the successful categories
|
|
assert result["total_returned"] >= 1
|
|
|
|
|
|
def test_one_errored_category_marks_incomplete():
|
|
"""The audit-trust failure mode: one category errors out and the summary lies.
|
|
Fix: complete=False, categories_with_errors >= 1.
|
|
"""
|
|
client = FakeAxlClient(error_on_columns=["fkcallingsearchspace_cgpnunknown"])
|
|
result = find_devices_using_css(client, "Some-CSS")
|
|
assert result["complete"] is False, (
|
|
"complete must be False when any category errored"
|
|
)
|
|
assert result["categories_with_errors"] >= 1
|
|
assert "device_cgpn_unknown_css" in result["error_categories"]
|
|
|
|
|
|
def test_multiple_errors_all_listed():
|
|
"""All errored categories must be enumerated in error_categories.
|
|
|
|
After issue #1 fix, several column suffixes (`_cgpnunknown`,
|
|
`_reroute`) appear on BOTH the device and devicepool tables, so
|
|
a single suffix in error_on_columns hits multiple categories.
|
|
The test verifies the relevant categories are surfaced.
|
|
"""
|
|
client = FakeAxlClient(
|
|
error_on_columns=[
|
|
"fkcallingsearchspace_pilotqueuefull", # huntpilotqueue only
|
|
]
|
|
)
|
|
result = find_devices_using_css(client, "Some-CSS")
|
|
assert result["complete"] is False
|
|
assert result["categories_with_errors"] >= 1
|
|
assert "huntpilot_queue_full_css" in result["error_categories"]
|
|
|
|
|
|
def test_error_in_multiple_tables_propagates():
|
|
"""A column suffix shared across tables (e.g., `_cgpnunknown` on both
|
|
device AND devicepool) errors out in BOTH categories — both must
|
|
appear in error_categories."""
|
|
client = FakeAxlClient(
|
|
error_on_columns=["fkcallingsearchspace_cgpnunknown"],
|
|
)
|
|
result = find_devices_using_css(client, "Some-CSS")
|
|
assert "device_cgpn_unknown_css" in result["error_categories"]
|
|
assert "devicepool_cgpn_unknown_css" in result["error_categories"]
|
|
|
|
|
|
def test_total_returned_does_not_include_error_categories():
|
|
"""An errored category contributes 0 to total_returned (correct behavior).
|
|
What's NEW: the response also flags that the count is partial.
|
|
"""
|
|
client = FakeAxlClient(error_on_columns=["fkcallingsearchspace_cgpnunknown"])
|
|
result = find_devices_using_css(client, "Some-CSS")
|
|
# The count itself is unchanged from before — what's new is the warning
|
|
assert result["complete"] is False
|
|
# The error category has no rows in references_by_category
|
|
err_cat = result["references_by_category"].get("device_cgpn_unknown_css", {})
|
|
assert "error" in err_cat
|
|
|
|
|
|
def test_css_not_found_returns_error_not_partial():
|
|
"""If the CSS lookup itself fails (CSS doesn't exist), we return the
|
|
'not found' error early, NOT a partial-failure response. Distinct
|
|
failure modes deserve distinct shapes.
|
|
"""
|
|
|
|
class CssNotFoundClient:
|
|
def execute_sql_query(self, sql):
|
|
if "callingsearchspace WHERE name" in sql:
|
|
return {"row_count": 0, "rows": []}
|
|
return {"row_count": 1, "rows": [{}]}
|
|
|
|
result = find_devices_using_css(CssNotFoundClient(), "Nonexistent-CSS")
|
|
assert "error" in result
|
|
assert "complete" not in result, (
|
|
"CSS-not-found is a hard error; we shouldn't dress it up as partial"
|
|
)
|
|
|
|
|
|
# ---- Issue #1 regression tests --------------------------------------------
|
|
# https://git.supported.systems/bingham/mcp-cucm-axl/issues/1
|
|
#
|
|
# Pre-fix: route_devices_using_css missed device.fkcallingsearchspace_cgpntransform
|
|
# and device.fkcallingsearchspace_cdpntransform. These are the columns trunks
|
|
# use to attach calling-party / called-party transformation CSSs. A CSS only
|
|
# referenced via these columns showed up as "0 references" — operator running
|
|
# impact analysis would conclude safe-to-delete and break outbound transforms.
|
|
|
|
from mcaxl.route_plan import _CSS_REFERENCE_QUERIES
|
|
|
|
|
|
def test_issue_1_cgpntransform_column_enumerated():
|
|
"""The specific column that triggered the issue is in our reference set."""
|
|
columns = {
|
|
(spec["table"], spec["column"])
|
|
for spec in _CSS_REFERENCE_QUERIES.values()
|
|
}
|
|
assert ("device", "fkcallingsearchspace_cgpntransform") in columns, (
|
|
"device.fkcallingsearchspace_cgpntransform must be enumerated; "
|
|
"see Gitea issue #1 — false-zero impact analysis on calling-party "
|
|
"transformation CSSs (e.g., XFORM-Outbound-ANI)"
|
|
)
|
|
|
|
|
|
def test_issue_1_cdpntransform_column_enumerated():
|
|
"""The sibling column (called-party transformation) is also enumerated."""
|
|
columns = {
|
|
(spec["table"], spec["column"])
|
|
for spec in _CSS_REFERENCE_QUERIES.values()
|
|
}
|
|
assert ("device", "fkcallingsearchspace_cdpntransform") in columns, (
|
|
"device.fkcallingsearchspace_cdpntransform must be enumerated; "
|
|
"same bug pattern as _cgpntransform (issue #1)"
|
|
)
|
|
|
|
|
|
def test_finds_trunk_via_cgpntransform_reference():
|
|
"""End-to-end: a trunk referencing a CSS via _cgpntransform should
|
|
appear in the impact analysis."""
|
|
|
|
class TrunkRefClient:
|
|
"""Returns 1 row only when queried for fkcallingsearchspace_cgpntransform."""
|
|
def execute_sql_query(self, sql):
|
|
if "callingsearchspace WHERE name" in sql:
|
|
return {"row_count": 1, "rows": [{"pkid": "fake-css-pkid"}]}
|
|
if "fkcallingsearchspace_cgpntransform" in sql:
|
|
return {
|
|
"row_count": 1,
|
|
"rows": [{
|
|
"name": "PSTN-Router-SIP-Trk",
|
|
"context": "Trunk",
|
|
"description": "the trunk that references this CSS",
|
|
}],
|
|
}
|
|
return {"row_count": 0, "rows": []}
|
|
|
|
result = find_devices_using_css(TrunkRefClient(), "XFORM-Outbound-ANI")
|
|
# Total must be ≥ 1 (the trunk reference), not 0
|
|
assert result["total_returned"] >= 1, (
|
|
"trunk referenced via _cgpntransform must surface in total_returned"
|
|
)
|
|
# And the specific category should be populated
|
|
cgpn_cat = result["references_by_category"].get("device_cgpn_xform_css")
|
|
assert cgpn_cat is not None and cgpn_cat.get("returned_count") == 1, (
|
|
f"device_cgpn_xform_css category should have 1 row; "
|
|
f"got: {result['references_by_category']}"
|
|
)
|
|
|
|
|
|
# ---- Comprehensive coverage --------------------------------------------
|
|
# The 71-column snapshot from CUCM 15.0.1.12900-234. If a future CUCM
|
|
# version adds a new fkcallingsearchspace_* column, this test fires red
|
|
# so the contributor knows to add it to _CSS_REFERENCE_QUERIES.
|
|
|
|
# Format: (table, column). Sourced from a SELECT against syscolumns
|
|
# 2026-04-26. Update when a new CUCM release lands.
|
|
_KNOWN_CSS_COLUMNS_FROM_CUCM_15 = frozenset({
|
|
# device — primary + 16 variants
|
|
("device", "fkcallingsearchspace"),
|
|
("device", "fkcallingsearchspace_aar"),
|
|
("device", "fkcallingsearchspace_calledintl"),
|
|
("device", "fkcallingsearchspace_callednational"),
|
|
("device", "fkcallingsearchspace_calledsubscriber"),
|
|
("device", "fkcallingsearchspace_calledunknown"),
|
|
("device", "fkcallingsearchspace_cdpntransform"),
|
|
("device", "fkcallingsearchspace_cgpningressdn"),
|
|
("device", "fkcallingsearchspace_cgpnintl"),
|
|
("device", "fkcallingsearchspace_cgpnnational"),
|
|
("device", "fkcallingsearchspace_cgpnsubscriber"),
|
|
("device", "fkcallingsearchspace_cgpntransform"),
|
|
("device", "fkcallingsearchspace_cgpnunknown"),
|
|
("device", "fkcallingsearchspace_rdntransform"),
|
|
("device", "fkcallingsearchspace_refer"),
|
|
("device", "fkcallingsearchspace_reroute"),
|
|
("device", "fkcallingsearchspace_restrict"),
|
|
# devicenumplanmap
|
|
("devicenumplanmap", "fkcallingsearchspace_monitoring"),
|
|
# devicepool — 16 variants (DP-level inheritance)
|
|
("devicepool", "fkcallingsearchspace_aar"),
|
|
("devicepool", "fkcallingsearchspace_adjunct"),
|
|
("devicepool", "fkcallingsearchspace_autoregistration"),
|
|
("devicepool", "fkcallingsearchspace_calledintl"),
|
|
("devicepool", "fkcallingsearchspace_callednational"),
|
|
("devicepool", "fkcallingsearchspace_calledsubscriber"),
|
|
("devicepool", "fkcallingsearchspace_calledunknown"),
|
|
("devicepool", "fkcallingsearchspace_cdpntransform"),
|
|
("devicepool", "fkcallingsearchspace_cgpningressdn"),
|
|
("devicepool", "fkcallingsearchspace_cgpnintl"),
|
|
("devicepool", "fkcallingsearchspace_cgpnnational"),
|
|
("devicepool", "fkcallingsearchspace_cgpnsubscriber"),
|
|
("devicepool", "fkcallingsearchspace_cgpntransform"),
|
|
("devicepool", "fkcallingsearchspace_cgpnunknown"),
|
|
("devicepool", "fkcallingsearchspace_cntdpntransform"),
|
|
("devicepool", "fkcallingsearchspace_mobility"),
|
|
("devicepool", "fkcallingsearchspace_rdntransform"),
|
|
# External call control + h323 + sipdevice + huntpilotqueue
|
|
("externalcallcontrolprofile", "fkcallingsearchspace_diversionrerouting"),
|
|
("h323device", "fkcallingsearchspace_cntdpntransform"),
|
|
("sipdevice", "fkcallingsearchspace_cntdpntransform"),
|
|
("huntpilotqueue", "fkcallingsearchspace_maxwaittime"),
|
|
("huntpilotqueue", "fkcallingsearchspace_noagent"),
|
|
("huntpilotqueue", "fkcallingsearchspace_pilotqueuefull"),
|
|
# incomingtransformationprofile (4)
|
|
("incomingtransformationprofile", "fkcallingsearchspace_intl"),
|
|
("incomingtransformationprofile", "fkcallingsearchspace_national"),
|
|
("incomingtransformationprofile", "fkcallingsearchspace_subscriber"),
|
|
("incomingtransformationprofile", "fkcallingsearchspace_unknown"),
|
|
# numplan — 18 forwarding/transformation CSSs
|
|
("numplan", "fkcallingsearchspace_cfapt"),
|
|
("numplan", "fkcallingsearchspace_cfb"),
|
|
("numplan", "fkcallingsearchspace_cfbint"),
|
|
("numplan", "fkcallingsearchspace_cfhr"),
|
|
("numplan", "fkcallingsearchspace_cfhrint"),
|
|
("numplan", "fkcallingsearchspace_cfna"),
|
|
("numplan", "fkcallingsearchspace_cfnaint"),
|
|
("numplan", "fkcallingsearchspace_cfur"),
|
|
("numplan", "fkcallingsearchspace_cfurint"),
|
|
("numplan", "fkcallingsearchspace_devicefailure"),
|
|
("numplan", "fkcallingsearchspace_mwi"),
|
|
("numplan", "fkcallingsearchspace_pff"),
|
|
("numplan", "fkcallingsearchspace_pffint"),
|
|
("numplan", "fkcallingsearchspace_pkmonfwdnoret"),
|
|
("numplan", "fkcallingsearchspace_pkmonfwdnoretint"),
|
|
("numplan", "fkcallingsearchspace_reroute"),
|
|
("numplan", "fkcallingsearchspace_revert"),
|
|
("numplan", "fkcallingsearchspace_sharedlineappear"),
|
|
("numplan", "fkcallingsearchspace_translation"),
|
|
# Profile tables + simple primary fkcallingsearchspace
|
|
("recordingprofile", "fkcallingsearchspace_callrecording"),
|
|
("routelist", "fkcallingsearchspace"),
|
|
("site", "fkcallingsearchspace"),
|
|
("usageprofile", "fkcallingsearchspace_blocking"),
|
|
("vipre164transformation", "fkcallingsearchspace_outgoingcdpntranf"),
|
|
("vipre164transformation", "fkcallingsearchspace_outgoingcgpntranf"),
|
|
("voicemessagingpilot", "fkcallingsearchspace"),
|
|
})
|
|
|
|
|
|
def test_complete_schema_coverage_against_known_columns():
|
|
"""If CUCM adds a new column type or we missed one, this test surfaces it.
|
|
Counts: 71 columns total in the CUCM 15.0.1.12900-234 snapshot.
|
|
"""
|
|
actual = {
|
|
(spec["table"], spec["column"])
|
|
for spec in _CSS_REFERENCE_QUERIES.values()
|
|
}
|
|
missing = _KNOWN_CSS_COLUMNS_FROM_CUCM_15 - actual
|
|
assert not missing, (
|
|
f"_CSS_REFERENCE_QUERIES is missing {len(missing)} known columns:\n"
|
|
+ "\n".join(f" {t}.{c}" for t, c in sorted(missing))
|
|
)
|
|
|
|
|
|
def test_no_duplicate_table_column_pairs():
|
|
"""Each (table, column) pair should map to exactly one category label.
|
|
Two categories pointing at the same column would double-count references."""
|
|
seen: dict[tuple, list[str]] = {}
|
|
for label, spec in _CSS_REFERENCE_QUERIES.items():
|
|
key = (spec["table"], spec["column"])
|
|
seen.setdefault(key, []).append(label)
|
|
duplicates = {k: v for k, v in seen.items() if len(v) > 1}
|
|
assert not duplicates, (
|
|
f"duplicate (table, column) pairs would double-count:\n"
|
|
+ "\n".join(f" {k}: {v}" for k, v in duplicates.items())
|
|
)
|