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
155 lines
5.6 KiB
Python
155 lines
5.6 KiB
Python
"""Tests for CUCM dial-plan wildcard pattern matching."""
|
|
|
|
import pytest
|
|
|
|
from mcaxl.route_plan import _pattern_matches_number, _wildcard_to_regex
|
|
|
|
|
|
class TestLiteralPatterns:
|
|
def test_exact_match(self):
|
|
assert _pattern_matches_number("1001", "1001")
|
|
|
|
def test_no_match(self):
|
|
assert not _pattern_matches_number("1001", "1002")
|
|
|
|
def test_escaped_plus(self):
|
|
assert _pattern_matches_number(r"\+15551234567", "+15551234567")
|
|
assert not _pattern_matches_number(r"\+15551234567", "15551234567")
|
|
|
|
|
|
class TestXWildcard:
|
|
def test_X_matches_any_digit(self):
|
|
assert _pattern_matches_number("XXXX", "1234")
|
|
assert _pattern_matches_number("XXXX", "9999")
|
|
|
|
def test_X_only_matches_digits(self):
|
|
assert not _pattern_matches_number("XXXX", "abc1")
|
|
assert not _pattern_matches_number("XXXX", "12") # too short
|
|
assert not _pattern_matches_number("XXXX", "12345") # too long
|
|
|
|
def test_X_mixed_with_literal(self):
|
|
assert _pattern_matches_number("9XXXX", "91234")
|
|
assert not _pattern_matches_number("9XXXX", "81234")
|
|
|
|
|
|
class TestBangWildcard:
|
|
def test_bang_matches_one_or_more(self):
|
|
assert _pattern_matches_number("9!", "91")
|
|
assert _pattern_matches_number("9!", "915551234567")
|
|
|
|
def test_bang_requires_at_least_one(self):
|
|
assert not _pattern_matches_number("9!", "9")
|
|
|
|
|
|
class TestCharacterClass:
|
|
def test_class_matches_any_in_set(self):
|
|
assert _pattern_matches_number("[2-9]XXX", "2000")
|
|
assert _pattern_matches_number("[2-9]XXX", "9999")
|
|
|
|
def test_class_excludes_outside_set(self):
|
|
assert not _pattern_matches_number("[2-9]XXX", "1000")
|
|
assert not _pattern_matches_number("[2-9]XXX", "0000")
|
|
|
|
|
|
class TestDotTerminator:
|
|
def test_dot_is_zero_width(self):
|
|
# CUCM's '.' is a marker for digit-discard, not a regex char
|
|
assert _pattern_matches_number("9.911", "9911")
|
|
assert _pattern_matches_number("10.911", "10911")
|
|
|
|
def test_dot_with_X_after(self):
|
|
assert _pattern_matches_number("9.[2-9]XXXXXXXXX", "92085551234")
|
|
|
|
|
|
class TestAtPattern:
|
|
def test_at_matches_any_digits(self):
|
|
# @ would normally apply a route filter; we treat it as "any digits"
|
|
assert _pattern_matches_number("9.@", "915551234567")
|
|
assert _pattern_matches_number("\\+.@", "+15551234567")
|
|
|
|
|
|
class TestEdgeCases:
|
|
def test_invalid_regex_returns_false(self):
|
|
# An unbalanced bracket should not raise
|
|
result = _pattern_matches_number("[", "1")
|
|
assert result is False
|
|
|
|
def test_empty_pattern(self):
|
|
assert _pattern_matches_number("", "")
|
|
assert not _pattern_matches_number("", "1")
|
|
|
|
def test_regex_anchors(self):
|
|
# Make sure we don't match a substring
|
|
assert not _pattern_matches_number("911", "1911")
|
|
assert not _pattern_matches_number("911", "9111")
|
|
|
|
|
|
class TestRegexConversion:
|
|
def test_X_to_digit_class(self):
|
|
# Bounded after Hamilton MAJOR #4 — `X` still matches a single digit
|
|
assert _wildcard_to_regex("X") == r"^\d$"
|
|
|
|
def test_bang_to_bounded_digits(self):
|
|
# Bounded after Hamilton MAJOR #4 — was \d+, now \d{1,N}.
|
|
# Adjacent !!! used to compile to (\d+)(\d+)(\d+) which has
|
|
# exponential backtracking on near-miss inputs.
|
|
regex = _wildcard_to_regex("!")
|
|
# Must be anchored, must contain a digit class with an upper bound.
|
|
assert regex.startswith("^") and regex.endswith("$")
|
|
assert r"\d{1," in regex, (
|
|
f"`!` must compile to bounded `\\d{{1,N}}` to prevent "
|
|
f"catastrophic backtracking; got: {regex}"
|
|
)
|
|
|
|
def test_anchored(self):
|
|
regex = _wildcard_to_regex("9XXX")
|
|
assert regex.startswith("^")
|
|
assert regex.endswith("$")
|
|
|
|
|
|
class TestUnclosedBracketIsExplicitError:
|
|
"""Hamilton review MAJOR #4 (part 2): unclosed `[` used to silently
|
|
fall back to treating the bracket as a literal. That produced wrong
|
|
matches with no warning. Fix: surface the malformed pattern as an
|
|
explicit error so the caller can flag it.
|
|
"""
|
|
|
|
def test_unclosed_bracket_raises(self):
|
|
import pytest as _pytest
|
|
with _pytest.raises(ValueError, match="bracket"):
|
|
_wildcard_to_regex("[0-9")
|
|
|
|
def test_unclosed_bracket_in_pattern_match_returns_false(self):
|
|
# _pattern_matches_number must catch the ValueError from the
|
|
# malformed pattern and return False (so a single bad pattern
|
|
# doesn't crash translation_chain).
|
|
assert _pattern_matches_number("[0-9", "1") is False
|
|
|
|
def test_well_formed_bracket_still_works(self):
|
|
# Sanity: the fix shouldn't break legitimate character classes
|
|
assert _pattern_matches_number("[2-9]XX", "456")
|
|
assert not _pattern_matches_number("[2-9]XX", "156")
|
|
|
|
|
|
class TestRegexBacktrackingBound:
|
|
"""Hamilton review MAJOR #4 (part 1): adjacent `!` wildcards used to
|
|
compile to `\\d+\\d+\\d+...` which is exponentially slow on near-miss
|
|
input. Bounded `\\d{1,N}` keeps it polynomial.
|
|
"""
|
|
|
|
def test_pathological_pattern_completes_quickly(self):
|
|
# 10 adjacent `!` matched against a long near-miss number.
|
|
# Pre-fix this could take seconds; bounded should finish in ms.
|
|
import time
|
|
pat = "!" * 10
|
|
# 30 digits + a trailing letter — guarantees no full match
|
|
num = "1" * 30 + "X"
|
|
t0 = time.monotonic()
|
|
result = _pattern_matches_number(pat, num)
|
|
elapsed = time.monotonic() - t0
|
|
assert result is False
|
|
assert elapsed < 0.5, (
|
|
f"pathological `!` chain must finish quickly even on near-miss; "
|
|
f"took {elapsed:.3f}s"
|
|
)
|