Initial mcp-cucm-axl
Read-only MCP server for Cisco Unified CM 15 AXL — built for LLM-driven
cluster auditing, with a particular focus on the Route Plan Report:
partitions, calling search spaces, route patterns, translation patterns,
called/calling party transformations, and digit-discard instructions.
Pairs intentionally with the sibling mcp-cisco-docs server (live
cluster state + vendor docs in one LLM context).
Architecture:
- zeep SOAP client to CUCM AXL
- WSDL bootstrap from Cisco's axlsqltoolkit.zip (auto-extract on
first launch; zip is gitignored, vendor-licensed)
- SQLite response cache at ~/.cache/mcp-cucm-axl/responses/
- Schema-grounded prompts that pull chunks from the sibling
cisco-docs index (docs_loader.py)
Read-only by structural guarantee — never registers AXL write methods
(no executeSQLUpdate, no add*/update*/remove*/apply*/reset*/restart*
tools). SQL queries also client-side validated (sql_validator.py) to
begin with SELECT or WITH.
Tools exposed:
Foundational: axl_version, axl_sql, axl_list_tables,
axl_describe_table, cache_stats, cache_clear
Route plan: route_partitions, route_calling_search_spaces,
route_patterns, route_inspect_pattern,
route_lists_and_groups, route_translation_chain,
route_digit_discard_instructions
Prompts (schema-grounded):
route_plan_overview, investigate_pattern, audit_routing,
cucm_sql_help
Tests cover cache, docs_loader, normalize, sql_validator, wildcard.
This commit is contained in:
commit
8b3da9d729
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
AXL_URL=https://cucm-pub:8443/axl
|
||||
AXL_USER=AxlUser
|
||||
AXL_PASS=TopSecretPasswordNoSpecialCharacters
|
||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
dist/
|
||||
build/
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
.env.local
|
||||
|
||||
# Cisco AXL Toolkit — vendor-licensed, do not redistribute
|
||||
axlsqltoolkit.zip
|
||||
schema/
|
||||
13
.mcp.json
Normal file
13
.mcp.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"cucm-axl": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"--directory",
|
||||
"/home/rpm/bingham/axl",
|
||||
"mcp-cucm-axl"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
130
README.md
Normal file
130
README.md
Normal file
@ -0,0 +1,130 @@
|
||||
# mcp-cucm-axl
|
||||
|
||||
Read-only MCP server for **Cisco Unified CM 15** AXL — built for LLM-driven
|
||||
cluster auditing, with a particular focus on the **Route Plan Report**:
|
||||
partitions, calling search spaces, route patterns, translation patterns,
|
||||
called/calling party transformations, and digit-discard instructions.
|
||||
|
||||
## Why this exists
|
||||
|
||||
CUCM's admin UI is great for one-config-at-a-time work but painful for
|
||||
audit/discovery questions like:
|
||||
|
||||
- "Which translation patterns rewrite the calling party number, and why?"
|
||||
- "Which CSSs include the `Internal_PT` partition, in what order?"
|
||||
- "Show me every route pattern targeting the SIP trunk to the carrier."
|
||||
- "Are there partitions defined but unreachable from any CSS?"
|
||||
|
||||
This server gives an LLM SQL access to CUCM's Informix data dictionary,
|
||||
plus focused tools that bake in the right joins for routing-audit work.
|
||||
Pair it with the sibling [`mcp-cisco-docs`](../docs/) server and the LLM
|
||||
gets vendor documentation alongside live cluster state — answering
|
||||
"is our config consistent with Cisco's recommended baseline?" in a single
|
||||
conversation.
|
||||
|
||||
## Read-only by structural guarantee
|
||||
|
||||
The server **never registers** AXL write methods. There is no
|
||||
`executeSQLUpdate`, no `add*`/`update*`/`remove*`/`apply*`/`reset*`/
|
||||
`restart*` tool. Read-only is enforced by *absence* of write operations,
|
||||
not by runtime sanitization. Defense-in-depth: SQL queries are also
|
||||
client-side validated to begin with `SELECT` or `WITH`.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Configure environment
|
||||
|
||||
Edit `.env` (already gitignored):
|
||||
|
||||
```env
|
||||
AXL_URL=https://cucm-pub:8443/axl
|
||||
AXL_USER=AxlUser
|
||||
AXL_PASS=...
|
||||
AXL_VERIFY_TLS=false # CUCM ships self-signed certs; default off
|
||||
AXL_CACHE_TTL=3600 # 1 hour; 0 disables caching
|
||||
AXL_WSDL_PATH= # optional explicit WSDL location
|
||||
CISCO_DOCS_INDEX_PATH= # optional override for prompt enrichment
|
||||
```
|
||||
|
||||
### 2. Bootstrap the AXL WSDL
|
||||
|
||||
Download the **Cisco AXL Toolkit** from your CUCM admin UI:
|
||||
|
||||
> Application → Plugins → Find → "Cisco AXL Toolkit" → Download
|
||||
|
||||
Drop the resulting `axlsqltoolkit.zip` into the project directory. On first
|
||||
launch, the server auto-extracts `schema/15.0/` into `~/.cache/mcp-cucm-axl/wsdl/15.0/`.
|
||||
The zip is gitignored (Cisco-licensed; not redistributable).
|
||||
|
||||
Alternatives (in resolution order):
|
||||
|
||||
```bash
|
||||
# A: explicit zip elsewhere
|
||||
export AXL_WSDL_ZIP=/path/to/axlsqltoolkit.zip
|
||||
|
||||
# B: explicit WSDL file
|
||||
export AXL_WSDL_PATH=/path/to/schema/15.0/AXLAPI.wsdl
|
||||
|
||||
# C: pre-populated cache directory
|
||||
mkdir -p ~/.cache/mcp-cucm-axl/wsdl/15.0/
|
||||
cp /path/to/schema/15.0/* ~/.cache/mcp-cucm-axl/wsdl/15.0/
|
||||
```
|
||||
|
||||
### 3. Install + run
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run mcp-cucm-axl
|
||||
```
|
||||
|
||||
Or via the bundled `.mcp.json`, automatically registered when Claude Code
|
||||
opens this directory.
|
||||
|
||||
## Tool surface
|
||||
|
||||
### Foundational
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `axl_version()` | Cluster version sanity check |
|
||||
| `axl_sql(query)` | Execute a SELECT against Informix data dictionary |
|
||||
| `axl_list_tables(pattern=None)` | Discover Informix tables |
|
||||
| `axl_describe_table(name)` | Column metadata for one table |
|
||||
| `cache_stats()`, `cache_clear(pattern=None)` | Cache plumbing |
|
||||
|
||||
### Route plan
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `route_partitions()` | All partitions with member counts |
|
||||
| `route_calling_search_spaces(name=None)` | CSS list with ordered partitions |
|
||||
| `route_patterns(kind=None, partition=None, filter=None)` | Route Plan Report |
|
||||
| `route_inspect_pattern(pattern, partition=None)` | One-pattern deep dive + reverse CSS lookup |
|
||||
| `route_lists_and_groups(name=None)` | Route list → route group → device chain |
|
||||
| `route_translation_chain(number, css_name=None)` | "What patterns might match this number?" (literal/prefix only) |
|
||||
| `route_digit_discard_instructions()` | DDI catalog |
|
||||
|
||||
## Prompts
|
||||
|
||||
Schema-grounded conversation seeds. They pull relevant chunks from the
|
||||
sibling `cisco-docs` index and embed them inline:
|
||||
|
||||
- `route_plan_overview` — fresh audit conversation seed
|
||||
- `investigate_pattern(pattern, partition=None)` — deep-dive a specific pattern
|
||||
- `audit_routing(focus="full")` — comprehensive audit walkthrough
|
||||
- `cucm_sql_help(question)` — catch-all for arbitrary SQL questions
|
||||
|
||||
## Cache
|
||||
|
||||
Responses are cached in SQLite at `~/.cache/mcp-cucm-axl/responses/axl_responses.sqlite`.
|
||||
Cache survives restarts. Clear with `cache_clear()` after a known config change.
|
||||
|
||||
## Notes
|
||||
|
||||
- `route_translation_chain` does literal/prefix matching only. CUCM's actual
|
||||
matcher evaluates wildcards (`X`, `!`, `[0-9]`, etc.) and selects the
|
||||
longest match. Treat results as "patterns to investigate" rather than
|
||||
"definitive route."
|
||||
- Pattern type codes (`tkpatternusage`) used by `route_patterns(kind=...)` are
|
||||
stable across CUCM versions but enumerated against the `typepatternusage`
|
||||
table at query time, so any cluster-specific custom types still work.
|
||||
40
pyproject.toml
Normal file
40
pyproject.toml
Normal file
@ -0,0 +1,40 @@
|
||||
[project]
|
||||
name = "mcp-cucm-axl"
|
||||
version = "0.1.0"
|
||||
description = "Read-only MCP server for CUCM 15 AXL — exposes executeSQLQuery + Informix data dictionary introspection, with schema-grounded prompts that pull from the sibling cisco-docs index. Built for LLM-driven cluster auditing."
|
||||
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.11"
|
||||
|
||||
dependencies = [
|
||||
"fastmcp>=3.2",
|
||||
"zeep>=4.3",
|
||||
"platformdirs>=4.9",
|
||||
"numpy>=1.26",
|
||||
"python-dotenv>=1.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mcp-cucm-axl = "mcp_cucm_axl.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mcp_cucm_axl"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
5
src/mcp_cucm_axl/__init__.py
Normal file
5
src/mcp_cucm_axl/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""mcp-cucm-axl — read-only MCP server for CUCM 15 AXL."""
|
||||
|
||||
from .server import main
|
||||
|
||||
__all__ = ["main"]
|
||||
129
src/mcp_cucm_axl/cache.py
Normal file
129
src/mcp_cucm_axl/cache.py
Normal file
@ -0,0 +1,129 @@
|
||||
"""SQLite-backed TTL cache for AXL responses.
|
||||
|
||||
Keyed on (method_name, sorted_kwargs_json). Cache survives server restarts,
|
||||
which makes exploratory audit sessions dramatically faster — the LLM can
|
||||
re-run the same `listPhone` queries across conversations without paying
|
||||
the SOAP round-trip every time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS axl_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
method TEXT NOT NULL,
|
||||
args_json TEXT NOT NULL,
|
||||
result_json TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS axl_cache_method_idx ON axl_cache(method);
|
||||
CREATE INDEX IF NOT EXISTS axl_cache_expires_idx ON axl_cache(expires_at);
|
||||
"""
|
||||
|
||||
|
||||
class AxlCache:
|
||||
"""SQLite TTL cache. Thread-safe via per-call connections."""
|
||||
|
||||
def __init__(self, db_path: Path, default_ttl: int):
|
||||
self.db_path = db_path
|
||||
self.default_ttl = default_ttl
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._conn() as c:
|
||||
c.executescript(SCHEMA)
|
||||
|
||||
def _conn(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path, isolation_level=None)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return conn
|
||||
|
||||
@staticmethod
|
||||
def _make_key(method: str, kwargs: dict) -> str:
|
||||
# sort_keys gives us a deterministic key regardless of dict order
|
||||
return f"{method}::{json.dumps(kwargs, sort_keys=True, default=str)}"
|
||||
|
||||
def get(self, method: str, kwargs: dict) -> Any | None:
|
||||
if self.default_ttl <= 0:
|
||||
return None
|
||||
key = self._make_key(method, kwargs)
|
||||
now = time.time()
|
||||
with self._conn() as c:
|
||||
row = c.execute(
|
||||
"SELECT result_json FROM axl_cache WHERE cache_key = ? AND expires_at > ?",
|
||||
(key, now),
|
||||
).fetchone()
|
||||
return json.loads(row[0]) if row else None
|
||||
|
||||
def set(self, method: str, kwargs: dict, result: Any, ttl: int | None = None) -> None:
|
||||
if self.default_ttl <= 0 and ttl is None:
|
||||
return
|
||||
ttl = ttl if ttl is not None else self.default_ttl
|
||||
if ttl <= 0:
|
||||
return
|
||||
key = self._make_key(method, kwargs)
|
||||
now = time.time()
|
||||
with self._conn() as c:
|
||||
c.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO axl_cache
|
||||
(cache_key, method, args_json, result_json, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
key,
|
||||
method,
|
||||
json.dumps(kwargs, sort_keys=True, default=str),
|
||||
json.dumps(result, default=str),
|
||||
now,
|
||||
now + ttl,
|
||||
),
|
||||
)
|
||||
|
||||
def stats(self) -> dict:
|
||||
now = time.time()
|
||||
with self._conn() as c:
|
||||
total = c.execute("SELECT COUNT(*) FROM axl_cache").fetchone()[0]
|
||||
live = c.execute(
|
||||
"SELECT COUNT(*) FROM axl_cache WHERE expires_at > ?", (now,)
|
||||
).fetchone()[0]
|
||||
by_method = {
|
||||
row[0]: row[1]
|
||||
for row in c.execute(
|
||||
"SELECT method, COUNT(*) FROM axl_cache "
|
||||
"WHERE expires_at > ? GROUP BY method ORDER BY 2 DESC",
|
||||
(now,),
|
||||
).fetchall()
|
||||
}
|
||||
return {
|
||||
"db_path": str(self.db_path),
|
||||
"default_ttl_seconds": self.default_ttl,
|
||||
"total_entries": total,
|
||||
"live_entries": live,
|
||||
"expired_entries": total - live,
|
||||
"by_method": by_method,
|
||||
}
|
||||
|
||||
def clear(self, method_pattern: str | None = None) -> int:
|
||||
with self._conn() as c:
|
||||
if method_pattern:
|
||||
cursor = c.execute(
|
||||
"DELETE FROM axl_cache WHERE method LIKE ?",
|
||||
(method_pattern.replace("*", "%"),),
|
||||
)
|
||||
else:
|
||||
cursor = c.execute("DELETE FROM axl_cache")
|
||||
return cursor.rowcount
|
||||
|
||||
def purge_expired(self) -> int:
|
||||
with self._conn() as c:
|
||||
cursor = c.execute("DELETE FROM axl_cache WHERE expires_at <= ?", (time.time(),))
|
||||
return cursor.rowcount
|
||||
299
src/mcp_cucm_axl/client.py
Normal file
299
src/mcp_cucm_axl/client.py
Normal file
@ -0,0 +1,299 @@
|
||||
"""AXL SOAP client wrapper.
|
||||
|
||||
Lazy connection — instantiated on first tool call, not at server boot.
|
||||
This means the FastMCP server registers tools and prompts immediately,
|
||||
even if the cluster is unreachable, and the user gets a clear error
|
||||
only when they actually invoke a tool that needs CUCM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import urllib3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from requests import Session
|
||||
from requests.auth import HTTPBasicAuth
|
||||
from zeep import Client, Settings
|
||||
from zeep.cache import SqliteCache
|
||||
from zeep.transports import Transport
|
||||
|
||||
from .cache import AxlCache
|
||||
from .sql_validator import validate_select
|
||||
from .wsdl_loader import resolve_wsdl_path
|
||||
|
||||
|
||||
class AxlClient:
|
||||
"""Lazy-loaded zeep client for CUCM AXL."""
|
||||
|
||||
def __init__(self, response_cache: AxlCache):
|
||||
self._client: Client | None = None
|
||||
self._service: Any = None
|
||||
self._response_cache = response_cache
|
||||
self._connection_error: str | None = None
|
||||
|
||||
def _ensure_connected(self) -> None:
|
||||
if self._service is not None:
|
||||
return
|
||||
if self._connection_error is not None:
|
||||
raise RuntimeError(self._connection_error)
|
||||
|
||||
try:
|
||||
url = os.environ["AXL_URL"]
|
||||
user = os.environ["AXL_USER"]
|
||||
password = os.environ["AXL_PASS"]
|
||||
except KeyError as e:
|
||||
raise RuntimeError(
|
||||
f"Missing required env var {e.args[0]}. "
|
||||
f"Set AXL_URL, AXL_USER, AXL_PASS in .env or the environment."
|
||||
) from None
|
||||
|
||||
# CUCM's AXL endpoint 302-redirects /axl to /axl/. The redirect
|
||||
# converts POST to GET (standard HTTP/1.1 behavior for 302), which
|
||||
# makes the SOAP request silently fail with an HTML status page.
|
||||
# Normalize the trailing slash so users don't need to remember.
|
||||
if not url.rstrip().endswith("/"):
|
||||
url = url.rstrip() + "/"
|
||||
|
||||
verify_tls = os.environ.get("AXL_VERIFY_TLS", "false").lower() in ("1", "true", "yes")
|
||||
if not verify_tls:
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
wsdl_path = resolve_wsdl_path()
|
||||
|
||||
session = Session()
|
||||
session.verify = verify_tls
|
||||
session.auth = HTTPBasicAuth(user, password)
|
||||
|
||||
# zeep's own WSDL cache (separate from our response cache) keeps
|
||||
# repeat startups fast — it parses the WSDL once and reuses
|
||||
from platformdirs import user_cache_dir
|
||||
zeep_cache_path = Path(user_cache_dir("mcp-cucm-axl")) / "zeep_wsdl.db"
|
||||
zeep_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
transport = Transport(
|
||||
session=session,
|
||||
cache=SqliteCache(path=str(zeep_cache_path), timeout=86400),
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
try:
|
||||
self._client = Client(
|
||||
wsdl=str(wsdl_path),
|
||||
settings=Settings(strict=False, xml_huge_tree=True),
|
||||
transport=transport,
|
||||
)
|
||||
# AXL endpoint is the AXL_URL itself; override the WSDL's default
|
||||
# service location which usually points at a placeholder host.
|
||||
self._service = self._client.create_service(
|
||||
"{http://www.cisco.com/AXLAPIService/}AXLAPIBinding",
|
||||
url,
|
||||
)
|
||||
print(
|
||||
f"[mcp-cucm-axl] connected to {url} (TLS verify={verify_tls})",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e:
|
||||
self._connection_error = f"AXL connection failed: {e}"
|
||||
raise RuntimeError(self._connection_error) from e
|
||||
|
||||
# ---- read-only operations ----
|
||||
|
||||
def get_ccm_version(self) -> dict:
|
||||
cached = self._response_cache.get("getCCMVersion", {})
|
||||
if cached is not None:
|
||||
return cached
|
||||
self._ensure_connected()
|
||||
resp = self._service.getCCMVersion()
|
||||
# zeep CompoundValue → dict; the actual payload is under "return"
|
||||
full = _zeep_to_dict(resp)
|
||||
result = full.get("return", full) if isinstance(full, dict) else full
|
||||
self._response_cache.set("getCCMVersion", {}, result, ttl=3600)
|
||||
return result
|
||||
|
||||
def execute_sql_query(self, query: str) -> dict:
|
||||
cleaned = validate_select(query)
|
||||
cached = self._response_cache.get("executeSQLQuery", {"sql": cleaned})
|
||||
if cached is not None:
|
||||
return {**cached, "_cache": "hit"}
|
||||
self._ensure_connected()
|
||||
resp = self._service.executeSQLQuery(sql=cleaned)
|
||||
rows = _parse_sql_rows(resp)
|
||||
result = {"row_count": len(rows), "rows": rows, "query": cleaned}
|
||||
self._response_cache.set("executeSQLQuery", {"sql": cleaned}, result)
|
||||
return {**result, "_cache": "miss"}
|
||||
|
||||
def list_informix_tables(self, pattern: str | None = None) -> dict:
|
||||
# systables is the Informix system catalog. tabid > 99 filters out
|
||||
# internal/system tables and leaves CUCM's data dictionary tables.
|
||||
if pattern:
|
||||
safe_pattern = pattern.replace("'", "''")
|
||||
sql = (
|
||||
"SELECT tabname FROM systables "
|
||||
f"WHERE tabid > 99 AND tabname LIKE '{safe_pattern}' "
|
||||
"ORDER BY tabname"
|
||||
)
|
||||
else:
|
||||
sql = "SELECT tabname FROM systables WHERE tabid > 99 ORDER BY tabname"
|
||||
result = self.execute_sql_query(sql)
|
||||
names = [row.get("tabname") for row in result.get("rows", []) if row.get("tabname")]
|
||||
return {"table_count": len(names), "tables": names, "pattern": pattern}
|
||||
|
||||
def describe_informix_table(self, table_name: str) -> dict:
|
||||
# Join syscolumns to systables to get column metadata for one table.
|
||||
# coltype encoding: low byte = type code, high bit = NOT NULL flag.
|
||||
safe = table_name.replace("'", "''")
|
||||
sql = (
|
||||
"SELECT c.colname, c.coltype, c.collength "
|
||||
"FROM syscolumns c, systables t "
|
||||
f"WHERE t.tabname = '{safe}' AND c.tabid = t.tabid "
|
||||
"ORDER BY c.colno"
|
||||
)
|
||||
result = self.execute_sql_query(sql)
|
||||
columns = []
|
||||
for row in result.get("rows", []):
|
||||
coltype_raw = int(row.get("coltype", 0))
|
||||
type_code = coltype_raw & 0xFF
|
||||
not_null = bool(coltype_raw & 0x100)
|
||||
columns.append({
|
||||
"name": row.get("colname"),
|
||||
"informix_type_code": type_code,
|
||||
"type": _INFORMIX_TYPE_NAMES.get(type_code, f"type_{type_code}"),
|
||||
"length": int(row.get("collength", 0)),
|
||||
"not_null": not_null,
|
||||
})
|
||||
if not columns:
|
||||
return {"table": table_name, "error": "Table not found or has no columns."}
|
||||
return {"table": table_name, "column_count": len(columns), "columns": columns}
|
||||
|
||||
|
||||
# Informix type codes — partial list, enough for CUCM's data dictionary.
|
||||
# Full list: https://www.ibm.com/docs/en/informix-servers/14.10?topic=tables-syscolumns
|
||||
_INFORMIX_TYPE_NAMES = {
|
||||
0: "CHAR",
|
||||
1: "SMALLINT",
|
||||
2: "INTEGER",
|
||||
3: "FLOAT",
|
||||
4: "SMALLFLOAT",
|
||||
5: "DECIMAL",
|
||||
6: "SERIAL",
|
||||
7: "DATE",
|
||||
8: "MONEY",
|
||||
10: "DATETIME",
|
||||
11: "BYTE",
|
||||
12: "TEXT",
|
||||
13: "VARCHAR",
|
||||
14: "INTERVAL",
|
||||
15: "NCHAR",
|
||||
16: "NVARCHAR",
|
||||
17: "INT8",
|
||||
18: "SERIAL8",
|
||||
19: "SET",
|
||||
20: "MULTISET",
|
||||
21: "LIST",
|
||||
22: "ROW",
|
||||
23: "COLLECTION",
|
||||
41: "LVARCHAR",
|
||||
43: "LVARCHAR",
|
||||
45: "BOOLEAN",
|
||||
}
|
||||
|
||||
|
||||
def _zeep_to_dict(obj: Any) -> Any:
|
||||
"""Recursively convert zeep CompoundValue objects to plain dicts/lists."""
|
||||
if obj is None:
|
||||
return None
|
||||
if hasattr(obj, "__values__"):
|
||||
return {k: _zeep_to_dict(v) for k, v in obj.__values__.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_zeep_to_dict(item) for item in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {k: _zeep_to_dict(v) for k, v in obj.items()}
|
||||
return obj
|
||||
|
||||
|
||||
def _parse_sql_rows(resp: Any) -> list[dict]:
|
||||
"""Pull the row list out of an executeSQLQuery response.
|
||||
|
||||
AXL's executeSQLQuery returns rows as raw lxml elements wrapped in
|
||||
`<return><row><colname>val</colname>...</row></return>`. Zeep doesn't
|
||||
schema-bind these because the columns vary per query — they come
|
||||
through as a list of `lxml.etree._Element` row objects with column
|
||||
children.
|
||||
|
||||
When the query matches zero rows, the response is `<return/>` (empty),
|
||||
which arrives as a CompoundValue with .return = None. In that case we
|
||||
must return [] — NOT fall back to parsing the response envelope itself,
|
||||
which would yield a phantom row of `{"return": None, "sequence": None}`.
|
||||
"""
|
||||
if resp is None:
|
||||
return []
|
||||
|
||||
# Find the row container at .return / ["return"] / __values__["return"]
|
||||
container = None
|
||||
for accessor in (
|
||||
lambda: getattr(resp, "return", None) if hasattr(resp, "return") else None,
|
||||
lambda: resp.__values__.get("return") if hasattr(resp, "__values__") else None,
|
||||
lambda: resp.get("return") if isinstance(resp, dict) else None,
|
||||
):
|
||||
try:
|
||||
v = accessor()
|
||||
except Exception:
|
||||
v = None
|
||||
if v is not None:
|
||||
container = v
|
||||
break
|
||||
|
||||
# No `return` member, or it's None → zero rows. Critical: do NOT fall
|
||||
# back to parsing `resp` itself, which would produce a phantom row.
|
||||
if container is None:
|
||||
return []
|
||||
|
||||
# If the container is itself the rows list, use it; else look for .row
|
||||
if isinstance(container, list):
|
||||
row_iter = container
|
||||
elif hasattr(container, "row"):
|
||||
row_iter = container.row or []
|
||||
elif isinstance(container, dict) and "row" in container:
|
||||
row_iter = container["row"] or []
|
||||
else:
|
||||
# Container present but no obvious row collection — try iterating it
|
||||
row_iter = list(container) if hasattr(container, "__iter__") else [container]
|
||||
|
||||
if not isinstance(row_iter, list):
|
||||
row_iter = [row_iter]
|
||||
|
||||
out = []
|
||||
for r in row_iter:
|
||||
# AXL's executeSQLQuery wraps each row as a list of lxml column
|
||||
# elements: [<Element colname1>, <Element colname2>, ...].
|
||||
if isinstance(r, list):
|
||||
out.append({
|
||||
child.tag: child.text
|
||||
for child in r
|
||||
if hasattr(child, "tag")
|
||||
})
|
||||
continue
|
||||
# Single lxml element with children (some response shapes)
|
||||
if hasattr(r, "tag") and not isinstance(r, str):
|
||||
try:
|
||||
out.append({child.tag: child.text for child in r})
|
||||
continue
|
||||
except TypeError:
|
||||
pass
|
||||
if hasattr(r, "__values__"):
|
||||
out.append({k: _stringify(v) for k, v in r.__values__.items()})
|
||||
elif isinstance(r, dict):
|
||||
out.append({k: _stringify(v) for k, v in r.items()})
|
||||
else:
|
||||
out.append({"value": str(r)})
|
||||
return out
|
||||
|
||||
|
||||
def _stringify(v: Any) -> Any:
|
||||
if v is None or isinstance(v, (str, int, float, bool)):
|
||||
return v
|
||||
return str(v)
|
||||
152
src/mcp_cucm_axl/docs_loader.py
Normal file
152
src/mcp_cucm_axl/docs_loader.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""Read the sibling cisco-docs index and surface chunks for prompt enrichment.
|
||||
|
||||
We deliberately do NOT load sentence-transformers here (would add ~500MB to
|
||||
the dep tree). Prompt parameters are well-bounded (topic strings, audit-type
|
||||
enums, table names), so substring-and-keyword matching against chunk text
|
||||
and heading_path gets us most of the value.
|
||||
|
||||
For free-text semantic queries, the prompt instructs the LLM to invoke the
|
||||
sibling cisco-docs MCP server's `search_docs` tool — composition over
|
||||
duplication.
|
||||
|
||||
Doc-name weighting: the cisco-docs index for CUCM is dominated by CLI
|
||||
reference chunks (~475 of 511) where most chunks are command syntax with
|
||||
no conceptual content. We bias toward conceptual docs (system-config,
|
||||
feature-config, admin) and penalize cli-reference for topical questions.
|
||||
The bias only matters for ranking — every doc still gets matched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Default to the sibling docs index in this monorepo. Override with env var
|
||||
# if mcp-cucm-axl gets used outside this layout.
|
||||
_DEFAULT_INDEX_DIR = Path("/home/rpm/bingham/docs/src/assets/.cisco-docs-index")
|
||||
|
||||
|
||||
# Doc-name multipliers — higher = preferred for conceptual prompts.
|
||||
# Keys match the `doc` field in indexed chunks.
|
||||
_DOC_WEIGHTS: dict[str, float] = {
|
||||
"system-config-guide": 3.0,
|
||||
"feature-config-guide": 2.5,
|
||||
"admin-guide": 2.0,
|
||||
"interop-sip-trunking-guide": 1.5,
|
||||
"security-guide": 1.2,
|
||||
"recording-use-cases": 1.0,
|
||||
"rtmt-guide": 0.8,
|
||||
"cli-reference": 0.3, # mostly command syntax, low conceptual signal
|
||||
"release-notes": 0.5,
|
||||
"hardware-compat": 0.2,
|
||||
"server-os-compat": 0.2,
|
||||
}
|
||||
|
||||
|
||||
class DocsIndex:
|
||||
"""In-memory chunk store with keyword filtering. Light, fast, no torch."""
|
||||
|
||||
def __init__(self, chunks: list[dict], meta: dict):
|
||||
self.chunks = chunks
|
||||
self.meta = meta
|
||||
|
||||
@classmethod
|
||||
def load(cls, index_dir: Path | None = None) -> "DocsIndex | None":
|
||||
index_dir = index_dir or Path(
|
||||
os.environ.get("CISCO_DOCS_INDEX_PATH", _DEFAULT_INDEX_DIR)
|
||||
)
|
||||
chunks_path = index_dir / "chunks.jsonl"
|
||||
meta_path = index_dir / "index_meta.json"
|
||||
|
||||
if not chunks_path.exists() or not meta_path.exists():
|
||||
print(
|
||||
f"[mcp-cucm-axl] cisco-docs index not found at {index_dir}; "
|
||||
f"prompts will run without schema enrichment.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
|
||||
meta = json.loads(meta_path.read_text())
|
||||
chunks = [
|
||||
json.loads(line)
|
||||
for line in chunks_path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
print(
|
||||
f"[mcp-cucm-axl] loaded {len(chunks)} doc chunks from {index_dir}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return cls(chunks, meta)
|
||||
|
||||
def cucm_chunks(self) -> list[dict]:
|
||||
return [c for c in self.chunks if c.get("product") == "cucm"]
|
||||
|
||||
def find(
|
||||
self,
|
||||
keywords: list[str],
|
||||
product: str = "cucm",
|
||||
max_chunks: int = 6,
|
||||
max_chars_per_chunk: int = 800,
|
||||
) -> list[dict]:
|
||||
"""Score chunks by keyword hits in heading_path + text. Lowercase-insensitive.
|
||||
|
||||
Heading hits weight 3x text hits — heading paths are a much better
|
||||
topical signal than incidental text mentions.
|
||||
"""
|
||||
if not keywords:
|
||||
return []
|
||||
kws = [k.lower() for k in keywords if k]
|
||||
|
||||
scored: list[tuple[float, dict]] = []
|
||||
for chunk in self.chunks:
|
||||
if product and chunk.get("product") != product:
|
||||
continue
|
||||
heading = " ".join(chunk.get("heading_path") or []).lower()
|
||||
text = (chunk.get("text") or "").lower()
|
||||
doc = chunk.get("doc") or ""
|
||||
doc_lower = doc.lower()
|
||||
raw = 0
|
||||
for k in kws:
|
||||
raw += heading.count(k) * 3
|
||||
raw += doc_lower.count(k) * 2
|
||||
raw += text.count(k)
|
||||
if raw > 0:
|
||||
weight = _DOC_WEIGHTS.get(doc, 1.0)
|
||||
scored.append((raw * weight, chunk))
|
||||
|
||||
scored.sort(key=lambda t: t[0], reverse=True)
|
||||
out = []
|
||||
for score, chunk in scored[:max_chunks]:
|
||||
text = chunk.get("text", "")
|
||||
if len(text) > max_chars_per_chunk:
|
||||
text = text[:max_chars_per_chunk] + "…"
|
||||
out.append({
|
||||
"score": round(score, 1),
|
||||
"heading_path": chunk.get("heading_path"),
|
||||
"doc": chunk.get("doc"),
|
||||
"version": chunk.get("version"),
|
||||
"source_path": chunk.get("source_path"),
|
||||
"text": text,
|
||||
"chunk_id": chunk.get("id"),
|
||||
})
|
||||
return out
|
||||
|
||||
def format_chunks_for_prompt(self, chunks: list[dict]) -> str:
|
||||
"""Render chunks as a markdown reference block for embedding in prompt seeds."""
|
||||
if not chunks:
|
||||
return "_No matching schema documentation found in the local index._"
|
||||
lines = []
|
||||
for c in chunks:
|
||||
heading = " > ".join(c.get("heading_path") or []) or "(no heading)"
|
||||
doc = c.get("doc", "")
|
||||
version = c.get("version", "")
|
||||
lines.append(f"### {heading} \n_source: {doc} ({version}) — score {c['score']}_")
|
||||
lines.append("")
|
||||
lines.append(c["text"])
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
125
src/mcp_cucm_axl/normalize.py
Normal file
125
src/mcp_cucm_axl/normalize.py
Normal file
@ -0,0 +1,125 @@
|
||||
"""Normalize Informix-flavored result values for LLM consumption.
|
||||
|
||||
CUCM's Informix database returns several encodings that are awkward for
|
||||
an LLM to interpret correctly without help:
|
||||
- Booleans as 't'/'f' strings (not native booleans).
|
||||
- Foreign-key codes like `tkreleasecausevalue='0'` that need a separate
|
||||
join to a `type*` table to get a human-readable name.
|
||||
|
||||
This module:
|
||||
1. Converts 't'/'f' to True/False for known boolean columns.
|
||||
2. Provides `TypeDecoder` — a small lazy cache that looks up tk* enum
|
||||
codes against their CUCM `type*` lookup tables on first use, so the
|
||||
output gets the human-readable name without us hardcoding any mappings.
|
||||
|
||||
The decoder reads from CUCM's own `typereleasecausevalue` etc. tables, so
|
||||
it stays correct across CUCM versions without maintenance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import AxlClient
|
||||
|
||||
|
||||
# Columns we know to be boolean from the CUCM data dictionary. Adding a
|
||||
# column here is safe — if it's not actually a 't'/'f' boolean, normalization
|
||||
# is a no-op (only literal 't' and 'f' strings get coerced).
|
||||
BOOLEAN_COLUMNS: frozenset[str] = frozenset({
|
||||
"block_enabled",
|
||||
"blockenable",
|
||||
"isemergencyservicenumber",
|
||||
"isanonymous",
|
||||
"iscallable",
|
||||
"ismessagewaitingon",
|
||||
"supportoverlapsending",
|
||||
"outsidedialtone",
|
||||
"rejectanonymouscall",
|
||||
"routethisdialplan",
|
||||
"usecallercss",
|
||||
"useoriginatorcss",
|
||||
"withtag",
|
||||
"withvalueclause",
|
||||
"personalroutingenabled",
|
||||
})
|
||||
|
||||
|
||||
def normalize_bool(value: object) -> object:
|
||||
"""'t'/'f' → True/False; passthrough everything else."""
|
||||
if value == "t":
|
||||
return True
|
||||
if value == "f":
|
||||
return False
|
||||
return value
|
||||
|
||||
|
||||
def normalize_row(row: dict) -> dict:
|
||||
"""Apply boolean normalization to known boolean columns in a row dict."""
|
||||
out = {}
|
||||
for k, v in row.items():
|
||||
out[k] = normalize_bool(v) if k in BOOLEAN_COLUMNS else v
|
||||
return out
|
||||
|
||||
|
||||
def normalize_rows(rows: list[dict]) -> list[dict]:
|
||||
return [normalize_row(r) for r in rows]
|
||||
|
||||
|
||||
class TypeDecoder:
|
||||
"""Lazily resolve `tk*` foreign-key codes to their human-readable names.
|
||||
|
||||
Usage:
|
||||
decoder = TypeDecoder(client)
|
||||
name = decoder.decode("tkreleasecausevalue", "0") # → "No Error"
|
||||
|
||||
On first call for a given tk* code, queries the matching `type*` table
|
||||
and caches the {enum: name} mapping. Subsequent calls hit the cache.
|
||||
"""
|
||||
|
||||
# Mapping from tk* column → type* lookup table. Add as we encounter
|
||||
# new codes that benefit from decoding. Codes not in this map remain
|
||||
# as the raw integer string (still queryable, just not decoded).
|
||||
TK_TO_TYPE: dict[str, str] = {
|
||||
"tkpatternusage": "typepatternusage",
|
||||
"tkreleasecausevalue": "typereleasecausevalue",
|
||||
"tkpatternprecedence": "typepatternprecedence",
|
||||
"tkpatternrouteclass": "typepatternrouteclass",
|
||||
"tkclass": "typeclass",
|
||||
"tkdeviceprotocol": "typedeviceprotocol",
|
||||
"tkmodel": "typemodel",
|
||||
"tkproduct": "typeproduct",
|
||||
"tkstatus_partyentrancetone": "typestatus_partyentrancetone",
|
||||
}
|
||||
|
||||
def __init__(self, client: "AxlClient"):
|
||||
self.client = client
|
||||
self._cache: dict[str, dict[str, str]] = {}
|
||||
|
||||
def _load_table(self, type_table: str) -> dict[str, str]:
|
||||
if type_table in self._cache:
|
||||
return self._cache[type_table]
|
||||
try:
|
||||
result = self.client.execute_sql_query(
|
||||
f"SELECT enum, name FROM {type_table}"
|
||||
)
|
||||
mapping = {
|
||||
str(r.get("enum")): r.get("name", "")
|
||||
for r in result.get("rows", [])
|
||||
if r.get("enum") is not None
|
||||
}
|
||||
except Exception:
|
||||
mapping = {}
|
||||
self._cache[type_table] = mapping
|
||||
return mapping
|
||||
|
||||
def decode(self, tk_column: str, code: object) -> object:
|
||||
"""Return the human-readable name for code, or the original code if unknown."""
|
||||
if code is None or code == "":
|
||||
return code
|
||||
type_table = self.TK_TO_TYPE.get(tk_column)
|
||||
if type_table is None:
|
||||
return code
|
||||
mapping = self._load_table(type_table)
|
||||
return mapping.get(str(code), code)
|
||||
803
src/mcp_cucm_axl/route_plan.py
Normal file
803
src/mcp_cucm_axl/route_plan.py
Normal file
@ -0,0 +1,803 @@
|
||||
"""Route plan tools — focused on CUCM call routing audit.
|
||||
|
||||
The CUCM dial plan is centered on one table: `numplan`. Every "pattern"
|
||||
(directory number, route pattern, translation pattern, hunt pilot, etc.) is
|
||||
a row there, distinguished by `tkpatternusage`. Calling/called party
|
||||
transformations and digit-discard instructions live as columns on the same
|
||||
row, which is why the Route Plan Report is essentially a single query.
|
||||
|
||||
Access control is in `callingsearchspace` + `callingsearchspacemember` —
|
||||
the latter is an ordered list (sortorder) of partitions inside each CSS.
|
||||
|
||||
Routing destinations: numplan → devicenumplanmap → device(tkclass='Route List')
|
||||
→ routelist → routegroup → routegroupdevicemap → device
|
||||
|
||||
Local Route Group resolution: route groups with no static members resolve
|
||||
through devicepoolroutegroupmap, which maps a calling device's device pool
|
||||
to the actual gateway-bearing route group. fkroutegroup_local is the named
|
||||
placeholder; fkroutegroup is the actual destination group.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .normalize import normalize_rows, normalize_row, normalize_bool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import AxlClient
|
||||
|
||||
|
||||
# Pattern type codes (numplan.tkpatternusage), verified against the
|
||||
# typepatternusage table in CUCM 15.0.1.12900. To inspect on a different
|
||||
# cluster: SELECT enum, name FROM typepatternusage ORDER BY enum.
|
||||
PATTERN_KINDS: dict[str, int] = {
|
||||
"call_park": 0,
|
||||
"conference": 1,
|
||||
"directory_number": 2,
|
||||
"translation": 3,
|
||||
"call_pickup_group": 4,
|
||||
"route": 5,
|
||||
"message_waiting": 6,
|
||||
"hunt_pilot": 7,
|
||||
"voicemail_port": 8,
|
||||
"device_template": 11,
|
||||
"directed_call_park": 12,
|
||||
"device_intercom": 13,
|
||||
"translation_intercom": 14,
|
||||
"translation_calling_party": 15,
|
||||
"called_party_xform": 20,
|
||||
"ils_learned_enterprise": 23,
|
||||
"ils_learned_e164": 24,
|
||||
"ils_learned_uri": 28,
|
||||
}
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
return s.replace("'", "''")
|
||||
|
||||
|
||||
def list_partitions(client: "AxlClient") -> dict:
|
||||
"""All route partitions with how many patterns and CSS members each has."""
|
||||
sql = """
|
||||
SELECT
|
||||
rp.name AS name,
|
||||
rp.description AS description,
|
||||
(SELECT COUNT(*) FROM numplan np WHERE np.fkroutepartition = rp.pkid) AS pattern_count,
|
||||
(SELECT COUNT(*) FROM callingsearchspacemember csm WHERE csm.fkroutepartition = rp.pkid) AS css_member_count
|
||||
FROM routepartition rp
|
||||
ORDER BY rp.name
|
||||
"""
|
||||
result = client.execute_sql_query(sql)
|
||||
return {
|
||||
"partition_count": result["row_count"],
|
||||
"partitions": normalize_rows(result["rows"]),
|
||||
}
|
||||
|
||||
|
||||
def list_calling_search_spaces(client: "AxlClient", name: str | None = None) -> dict:
|
||||
"""Calling search spaces with their ordered partition list.
|
||||
|
||||
If `name` is given, return only that CSS. Otherwise return all CSS
|
||||
grouped, with each one's partitions in sortorder.
|
||||
"""
|
||||
where = f"WHERE css.name = '{_esc(name)}'" if name else ""
|
||||
sql = f"""
|
||||
SELECT
|
||||
css.name AS css_name,
|
||||
css.description AS css_description,
|
||||
rp.name AS partition_name,
|
||||
csm.sortorder AS sortorder
|
||||
FROM callingsearchspace css
|
||||
LEFT OUTER JOIN callingsearchspacemember csm ON csm.fkcallingsearchspace = css.pkid
|
||||
LEFT OUTER JOIN routepartition rp ON csm.fkroutepartition = rp.pkid
|
||||
{where}
|
||||
ORDER BY css.name, csm.sortorder
|
||||
"""
|
||||
result = client.execute_sql_query(sql)
|
||||
|
||||
grouped: dict[str, dict] = {}
|
||||
for row in result["rows"]:
|
||||
css = row.get("css_name")
|
||||
if not css:
|
||||
continue
|
||||
if css not in grouped:
|
||||
grouped[css] = {
|
||||
"name": css,
|
||||
"description": row.get("css_description"),
|
||||
"partitions": [],
|
||||
}
|
||||
if row.get("partition_name"):
|
||||
grouped[css]["partitions"].append({
|
||||
"name": row["partition_name"],
|
||||
"sortorder": row.get("sortorder"),
|
||||
})
|
||||
|
||||
css_list = list(grouped.values())
|
||||
return {"css_count": len(css_list), "calling_search_spaces": css_list}
|
||||
|
||||
|
||||
def list_patterns(
|
||||
client: "AxlClient",
|
||||
kind: str | None = None,
|
||||
partition: str | None = None,
|
||||
filter_substring: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> dict:
|
||||
"""The Route Plan Report — patterns with their transformations.
|
||||
|
||||
Args:
|
||||
kind: One of PATTERN_KINDS keys, or None for all. e.g. "route", "translation".
|
||||
partition: Filter by partition name.
|
||||
filter_substring: Substring match against pattern text (LIKE '%X%').
|
||||
limit: Max rows. Defaults to 500.
|
||||
"""
|
||||
where_clauses = []
|
||||
if kind is not None:
|
||||
if kind not in PATTERN_KINDS:
|
||||
return {
|
||||
"error": f"Unknown kind {kind!r}. Valid: {sorted(PATTERN_KINDS)}",
|
||||
}
|
||||
where_clauses.append(f"np.tkpatternusage = {PATTERN_KINDS[kind]}")
|
||||
if partition:
|
||||
where_clauses.append(f"rp.name = '{_esc(partition)}'")
|
||||
if filter_substring:
|
||||
where_clauses.append(f"np.dnorpattern LIKE '%{_esc(filter_substring)}%'")
|
||||
|
||||
where = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
||||
|
||||
sql = f"""
|
||||
SELECT FIRST {int(limit)}
|
||||
np.dnorpattern AS pattern,
|
||||
np.description AS description,
|
||||
tpu.name AS pattern_type,
|
||||
np.tkpatternusage AS pattern_type_code,
|
||||
rp.name AS partition_name,
|
||||
np.callingpartytransformationmask AS calling_party_xform_mask,
|
||||
np.calledpartytransformationmask AS called_party_xform_mask,
|
||||
np.prefixdigitsout AS prefix_digits_out,
|
||||
np.callingpartyprefixdigits AS calling_prefix_digits,
|
||||
ddi.name AS digit_discard_instructions,
|
||||
np.blockenable AS block_enabled,
|
||||
np.pkid AS pkid
|
||||
FROM numplan np
|
||||
LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
|
||||
LEFT OUTER JOIN typepatternusage tpu ON np.tkpatternusage = tpu.enum
|
||||
LEFT OUTER JOIN digitdiscardinstruction ddi ON np.fkdigitdiscardinstruction = ddi.pkid
|
||||
{where}
|
||||
ORDER BY rp.name, np.dnorpattern
|
||||
"""
|
||||
result = client.execute_sql_query(sql)
|
||||
return {
|
||||
"pattern_count": result["row_count"],
|
||||
"filter": {"kind": kind, "partition": partition, "substring": filter_substring},
|
||||
"patterns": normalize_rows(result["rows"]),
|
||||
}
|
||||
|
||||
|
||||
def inspect_pattern(
|
||||
client: "AxlClient",
|
||||
pattern: str,
|
||||
partition: str | None = None,
|
||||
) -> dict:
|
||||
"""Deep dive on one pattern — transforms, target, reverse CSS lookup."""
|
||||
where = f"np.dnorpattern = '{_esc(pattern)}'"
|
||||
if partition:
|
||||
where += f" AND rp.name = '{_esc(partition)}'"
|
||||
|
||||
detail_sql = f"""
|
||||
SELECT
|
||||
np.dnorpattern AS pattern,
|
||||
np.description AS description,
|
||||
tpu.name AS pattern_type,
|
||||
rp.name AS partition_name,
|
||||
np.callingpartytransformationmask AS calling_party_xform_mask,
|
||||
np.calledpartytransformationmask AS called_party_xform_mask,
|
||||
np.prefixdigitsout AS prefix_digits_out,
|
||||
np.callingpartyprefixdigits AS calling_prefix_digits,
|
||||
ddi.name AS digit_discard_instructions,
|
||||
cssxlate.name AS css_for_translation,
|
||||
rf.name AS route_filter_name,
|
||||
rf.clause AS route_filter_clause,
|
||||
np.blockenable AS block_enabled,
|
||||
tprc.name AS release_cause,
|
||||
np.pkid AS pkid
|
||||
FROM numplan np
|
||||
LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
|
||||
LEFT OUTER JOIN typepatternusage tpu ON np.tkpatternusage = tpu.enum
|
||||
LEFT OUTER JOIN digitdiscardinstruction ddi ON np.fkdigitdiscardinstruction = ddi.pkid
|
||||
LEFT OUTER JOIN callingsearchspace cssxlate ON np.fkcallingsearchspace_translation = cssxlate.pkid
|
||||
LEFT OUTER JOIN routefilter rf ON np.fkroutefilter = rf.pkid
|
||||
LEFT OUTER JOIN typereleasecausevalue tprc ON np.tkreleasecausevalue = tprc.enum
|
||||
WHERE {where}
|
||||
"""
|
||||
detail = client.execute_sql_query(detail_sql)
|
||||
if not detail["rows"]:
|
||||
return {"error": f"Pattern {pattern!r} not found" + (f" in partition {partition!r}" if partition else "")}
|
||||
if len(detail["rows"]) > 1 and not partition:
|
||||
return {
|
||||
"error": f"Multiple patterns named {pattern!r} in different partitions; specify partition.",
|
||||
"matches": [
|
||||
{"pattern": r["pattern"], "partition": r["partition_name"]}
|
||||
for r in detail["rows"]
|
||||
],
|
||||
}
|
||||
|
||||
row = normalize_row(detail["rows"][0])
|
||||
partition_name = row.get("partition_name")
|
||||
pattern_pkid = row.get("pkid")
|
||||
|
||||
# Reverse CSS lookup: which calling search spaces include this pattern's partition?
|
||||
reachable_from = []
|
||||
if partition_name:
|
||||
css_sql = f"""
|
||||
SELECT css.name AS css_name, csm.sortorder AS sortorder
|
||||
FROM callingsearchspace css
|
||||
JOIN callingsearchspacemember csm ON csm.fkcallingsearchspace = css.pkid
|
||||
JOIN routepartition rp ON csm.fkroutepartition = rp.pkid
|
||||
WHERE rp.name = '{_esc(partition_name)}'
|
||||
ORDER BY css.name
|
||||
"""
|
||||
css_result = client.execute_sql_query(css_sql)
|
||||
reachable_from = css_result["rows"]
|
||||
|
||||
# Forward routing: where does this pattern actually route?
|
||||
destination = None
|
||||
route_list_chain = None
|
||||
if pattern_pkid:
|
||||
destination = resolve_pattern_destination(client, pattern_pkid)
|
||||
# If destination is a Route List, expand the route group / device chain
|
||||
if destination and destination.get("destination_class") == "Route List":
|
||||
chain = list_route_lists_and_groups(client, name=destination["destination_name"])
|
||||
if chain.get("route_lists"):
|
||||
route_list_chain = chain["route_lists"][0]
|
||||
|
||||
return {
|
||||
"pattern": row,
|
||||
"reachable_from_css": reachable_from,
|
||||
"destination": destination,
|
||||
"route_list_chain": route_list_chain,
|
||||
}
|
||||
|
||||
|
||||
def list_route_lists_and_groups(client: "AxlClient", name: str | None = None) -> dict:
|
||||
"""Route lists with their ordered route groups and member gateways/trunks.
|
||||
|
||||
Schema chain:
|
||||
device (tkclass='Route List') → the "Route List" header
|
||||
↳ routelist (fkdevice = ↑) → list of route group steps (selectionorder)
|
||||
↳ routegroup (fkroutegroup) → the route group at this step
|
||||
↳ routegroupdevicemap → ordered (deviceselectionorder) gateways/trunks
|
||||
↳ device (fkdevice = ↑) → the actual gateway/trunk
|
||||
"""
|
||||
where = f"AND rl_dev.name = '{_esc(name)}'" if name else ""
|
||||
sql = f"""
|
||||
SELECT
|
||||
rl_dev.name AS route_list_name,
|
||||
rl_dev.description AS route_list_description,
|
||||
rg.name AS route_group_name,
|
||||
rl.selectionorder AS group_order,
|
||||
gw.name AS device_name,
|
||||
rgdm.deviceselectionorder AS device_order,
|
||||
tc_gw.name AS device_class
|
||||
FROM device rl_dev
|
||||
JOIN typeclass tc_rl ON rl_dev.tkclass = tc_rl.enum
|
||||
LEFT OUTER JOIN routelist rl ON rl.fkdevice = rl_dev.pkid
|
||||
LEFT OUTER JOIN routegroup rg ON rl.fkroutegroup = rg.pkid
|
||||
LEFT OUTER JOIN routegroupdevicemap rgdm ON rgdm.fkroutegroup = rg.pkid
|
||||
LEFT OUTER JOIN device gw ON rgdm.fkdevice = gw.pkid
|
||||
LEFT OUTER JOIN typeclass tc_gw ON gw.tkclass = tc_gw.enum
|
||||
WHERE tc_rl.name = 'Route List' {where}
|
||||
ORDER BY rl_dev.name, rl.selectionorder, rgdm.deviceselectionorder
|
||||
"""
|
||||
result = client.execute_sql_query(sql)
|
||||
|
||||
grouped: dict[str, dict] = {}
|
||||
for row in result["rows"]:
|
||||
rl_name = row.get("route_list_name")
|
||||
if not rl_name:
|
||||
continue
|
||||
if rl_name not in grouped:
|
||||
grouped[rl_name] = {
|
||||
"name": rl_name,
|
||||
"description": row.get("route_list_description"),
|
||||
"route_groups": {},
|
||||
}
|
||||
rg_name = row.get("route_group_name")
|
||||
if rg_name:
|
||||
if rg_name not in grouped[rl_name]["route_groups"]:
|
||||
grouped[rl_name]["route_groups"][rg_name] = {
|
||||
"name": rg_name,
|
||||
"order_within_route_list": _to_int(row.get("group_order")),
|
||||
"devices": [],
|
||||
}
|
||||
if row.get("device_name"):
|
||||
grouped[rl_name]["route_groups"][rg_name]["devices"].append({
|
||||
"name": row["device_name"],
|
||||
"class": row.get("device_class"),
|
||||
"order_within_group": _to_int(row.get("device_order")),
|
||||
})
|
||||
|
||||
out = []
|
||||
for rl in grouped.values():
|
||||
# Sort route groups by their order, and annotate empty groups —
|
||||
# which on CUCM usually means "uses device-pool-assigned local
|
||||
# route group" (CUCM's Standard Local Route Group feature)
|
||||
ordered_groups = sorted(
|
||||
rl["route_groups"].values(),
|
||||
key=lambda rg: rg.get("order_within_route_list") or 0,
|
||||
)
|
||||
for g in ordered_groups:
|
||||
if not g["devices"]:
|
||||
g["_note"] = (
|
||||
"No static device members. Likely resolves to a Local "
|
||||
"Route Group via the calling device's device pool at "
|
||||
"call-time (CUCM Standard Local Route Group feature)."
|
||||
)
|
||||
rl["route_groups"] = ordered_groups
|
||||
out.append(rl)
|
||||
|
||||
return {"route_list_count": len(out), "route_lists": out}
|
||||
|
||||
|
||||
def _to_int(v: object) -> int | None:
|
||||
"""Cast Informix string-encoded integers to int; passthrough None/non-numeric."""
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Device Pool → Route Group resolution (Local Route Group feature)
|
||||
# ====================================================================
|
||||
|
||||
def list_device_pool_route_groups(
|
||||
client: "AxlClient",
|
||||
device_pool_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Show how each device pool resolves Local Route Group placeholders.
|
||||
|
||||
CUCM's Standard Local Route Group feature lets a route list step reference
|
||||
a *named* route group (the "local placeholder" — e.g., "Primary PSTN Route
|
||||
Group"), and at call-time the calling phone's device pool determines which
|
||||
*actual* route group (with real gateways/trunks) gets used.
|
||||
|
||||
Schema:
|
||||
devicepoolroutegroupmap.fkdevicepool → which DP
|
||||
devicepoolroutegroupmap.fkroutegroup_local → named placeholder ref'd by route lists
|
||||
devicepoolroutegroupmap.fkroutegroup → actual route group with gateways
|
||||
|
||||
Args:
|
||||
device_pool_name: If given, return only that DP's mappings.
|
||||
"""
|
||||
where = ""
|
||||
if device_pool_name:
|
||||
where = f"WHERE dp.name = '{_esc(device_pool_name)}'"
|
||||
sql = f"""
|
||||
SELECT
|
||||
dp.name AS device_pool,
|
||||
placeholder.name AS local_placeholder,
|
||||
target.name AS resolves_to_route_group
|
||||
FROM devicepoolroutegroupmap m
|
||||
JOIN devicepool dp ON m.fkdevicepool = dp.pkid
|
||||
LEFT OUTER JOIN routegroup placeholder ON m.fkroutegroup_local = placeholder.pkid
|
||||
LEFT OUTER JOIN routegroup target ON m.fkroutegroup = target.pkid
|
||||
{where}
|
||||
ORDER BY dp.name, placeholder.name
|
||||
"""
|
||||
result = client.execute_sql_query(sql)
|
||||
grouped: dict[str, dict] = {}
|
||||
for row in result["rows"]:
|
||||
dp = row.get("device_pool")
|
||||
if not dp:
|
||||
continue
|
||||
if dp not in grouped:
|
||||
grouped[dp] = {"name": dp, "local_route_group_resolutions": []}
|
||||
grouped[dp]["local_route_group_resolutions"].append({
|
||||
"placeholder": row.get("local_placeholder"),
|
||||
"resolves_to": row.get("resolves_to_route_group"),
|
||||
})
|
||||
return {
|
||||
"device_pool_count": len(grouped),
|
||||
"device_pools": list(grouped.values()),
|
||||
}
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# CSS impact analysis: which devices/lines/patterns reference this CSS
|
||||
# ====================================================================
|
||||
|
||||
# CSS reference points: for each, the SQL is hand-written because the
|
||||
# identifier column varies per table. Each entry returns rows with a
|
||||
# common shape: name, context (e.g. partition), table, column.
|
||||
_CSS_REFERENCE_QUERIES: dict[str, dict] = {
|
||||
# Line-level forwarding CSSs (call-forward variants on a DN)
|
||||
"line_call_forward_all_css": {
|
||||
"table": "numplan", "column": "fkcallingsearchspace_cfapt",
|
||||
"sql": """
|
||||
SELECT np.dnorpattern AS name, rp.name AS context, np.description AS description
|
||||
FROM numplan np LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
|
||||
WHERE np.fkcallingsearchspace_cfapt = '{pkid}'
|
||||
ORDER BY rp.name, np.dnorpattern
|
||||
""",
|
||||
},
|
||||
"line_call_forward_busy_css": {
|
||||
"table": "numplan", "column": "fkcallingsearchspace_cfb",
|
||||
"sql": """
|
||||
SELECT np.dnorpattern AS name, rp.name AS context, np.description AS description
|
||||
FROM numplan np LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
|
||||
WHERE np.fkcallingsearchspace_cfb = '{pkid}'
|
||||
""",
|
||||
},
|
||||
"line_call_forward_no_answer_css": {
|
||||
"table": "numplan", "column": "fkcallingsearchspace_cfna",
|
||||
"sql": """
|
||||
SELECT np.dnorpattern AS name, rp.name AS context, np.description AS description
|
||||
FROM numplan np LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
|
||||
WHERE np.fkcallingsearchspace_cfna = '{pkid}'
|
||||
""",
|
||||
},
|
||||
"line_call_forward_unregistered_css": {
|
||||
"table": "numplan", "column": "fkcallingsearchspace_cfur",
|
||||
"sql": """
|
||||
SELECT np.dnorpattern AS name, rp.name AS context, np.description AS description
|
||||
FROM numplan np LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
|
||||
WHERE np.fkcallingsearchspace_cfur = '{pkid}'
|
||||
""",
|
||||
},
|
||||
"translation_pattern_css": {
|
||||
"table": "numplan", "column": "fkcallingsearchspace_translation",
|
||||
"sql": """
|
||||
SELECT np.dnorpattern AS name, rp.name AS context, np.description AS description
|
||||
FROM numplan np LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
|
||||
WHERE np.fkcallingsearchspace_translation = '{pkid}'
|
||||
""",
|
||||
},
|
||||
"line_shared_appearance_css": {
|
||||
"table": "numplan", "column": "fkcallingsearchspace_sharedlineappear",
|
||||
"sql": """
|
||||
SELECT np.dnorpattern AS name, rp.name AS context, np.description AS description
|
||||
FROM numplan np LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
|
||||
WHERE np.fkcallingsearchspace_sharedlineappear = '{pkid}'
|
||||
""",
|
||||
},
|
||||
# Device-level CSS variants
|
||||
"device_reroute_css": {
|
||||
"table": "device", "column": "fkcallingsearchspace_reroute",
|
||||
"sql": """
|
||||
SELECT d.name AS name, tc.name AS context, d.description AS description
|
||||
FROM device d LEFT OUTER JOIN typeclass tc ON d.tkclass = tc.enum
|
||||
WHERE d.fkcallingsearchspace_reroute = '{pkid}'
|
||||
""",
|
||||
},
|
||||
"device_restrict_css": {
|
||||
"table": "device", "column": "fkcallingsearchspace_restrict",
|
||||
"sql": """
|
||||
SELECT d.name AS name, tc.name AS context, d.description AS description
|
||||
FROM device d LEFT OUTER JOIN typeclass tc ON d.tkclass = tc.enum
|
||||
WHERE d.fkcallingsearchspace_restrict = '{pkid}'
|
||||
""",
|
||||
},
|
||||
"device_refer_css": {
|
||||
"table": "device", "column": "fkcallingsearchspace_refer",
|
||||
"sql": """
|
||||
SELECT d.name AS name, tc.name AS context, d.description AS description
|
||||
FROM device d LEFT OUTER JOIN typeclass tc ON d.tkclass = tc.enum
|
||||
WHERE d.fkcallingsearchspace_refer = '{pkid}'
|
||||
""",
|
||||
},
|
||||
"device_rdn_transform_css": {
|
||||
"table": "device", "column": "fkcallingsearchspace_rdntransform",
|
||||
"sql": """
|
||||
SELECT d.name AS name, tc.name AS context, d.description AS description
|
||||
FROM device d LEFT OUTER JOIN typeclass tc ON d.tkclass = tc.enum
|
||||
WHERE d.fkcallingsearchspace_rdntransform = '{pkid}'
|
||||
""",
|
||||
},
|
||||
# Voicemail pilot — has directorynumber instead of name
|
||||
"voicemail_pilot_css": {
|
||||
"table": "voicemessagingpilot", "column": "fkcallingsearchspace",
|
||||
"sql": """
|
||||
SELECT directorynumber AS name, NULL AS context, description
|
||||
FROM voicemessagingpilot
|
||||
WHERE fkcallingsearchspace = '{pkid}'
|
||||
""",
|
||||
},
|
||||
# Route lists — routelist is a join table; identify by the route-list device name
|
||||
"route_list_css": {
|
||||
"table": "routelist", "column": "fkcallingsearchspace",
|
||||
"sql": """
|
||||
SELECT d.name AS name, tc.name AS context, d.description AS description
|
||||
FROM routelist rl JOIN device d ON rl.fkdevice = d.pkid
|
||||
LEFT OUTER JOIN typeclass tc ON d.tkclass = tc.enum
|
||||
WHERE rl.fkcallingsearchspace = '{pkid}'
|
||||
""",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def find_devices_using_css(client: "AxlClient", css_name: str) -> dict:
|
||||
"""Impact analysis: enumerate every reference to this CSS across the schema.
|
||||
|
||||
Useful before changing or removing a CSS — answers "what would break if
|
||||
I touched this CSS?" Cross-references the major schema tables that hold
|
||||
fkcallingsearchspace* columns.
|
||||
|
||||
Returns groups of references by category. Each reference has:
|
||||
- name: the object identifier (pattern text, device name, or DN)
|
||||
- context: partition (for patterns) or device class (for devices)
|
||||
- description: free-text description
|
||||
- _table, _column: which fk-column referenced this CSS
|
||||
"""
|
||||
safe = _esc(css_name)
|
||||
css_lookup = client.execute_sql_query(
|
||||
f"SELECT pkid FROM callingsearchspace WHERE name = '{safe}'"
|
||||
)
|
||||
if not css_lookup["rows"]:
|
||||
return {"error": f"CSS {css_name!r} not found", "css_name": css_name}
|
||||
css_pkid = css_lookup["rows"][0]["pkid"]
|
||||
safe_pkid = _esc(css_pkid)
|
||||
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
for label, spec in _CSS_REFERENCE_QUERIES.items():
|
||||
sql = spec["sql"].format(pkid=safe_pkid)
|
||||
try:
|
||||
result = client.execute_sql_query(sql)
|
||||
if result["rows"]:
|
||||
grouped[label] = [
|
||||
{**r, "_table": spec["table"], "_column": spec["column"]}
|
||||
for r in result["rows"]
|
||||
]
|
||||
except Exception as e:
|
||||
# Don't let one failed reference point block the whole audit
|
||||
grouped[label] = [{
|
||||
"_error": str(e)[:200],
|
||||
"_table": spec["table"],
|
||||
"_column": spec["column"],
|
||||
}]
|
||||
|
||||
total = sum(len(v) for v in grouped.values())
|
||||
return {
|
||||
"css_name": css_name,
|
||||
"css_pkid": css_pkid,
|
||||
"total_references": total,
|
||||
"references_by_category": grouped,
|
||||
}
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Route Filters
|
||||
# ====================================================================
|
||||
|
||||
def list_route_filters(client: "AxlClient", name: str | None = None) -> dict:
|
||||
"""List route filters with their member clauses.
|
||||
|
||||
Route filters compose with @-pattern (NANPA) route patterns to constrain
|
||||
matches — e.g., a filter that matches only when AREA-CODE == 208 narrows
|
||||
a `9.@` pattern to in-state calls. Members specify (digit, operator, tag)
|
||||
triples; the clause column shows the assembled expression.
|
||||
"""
|
||||
where = f"WHERE rf.name = '{_esc(name)}'" if name else ""
|
||||
sql = f"""
|
||||
SELECT
|
||||
rf.name AS filter_name,
|
||||
rf.clause AS clause,
|
||||
dp.name AS dial_plan,
|
||||
rf.pkid AS pkid
|
||||
FROM routefilter rf
|
||||
LEFT OUTER JOIN dialplan dp ON rf.fkdialplan = dp.pkid
|
||||
{where}
|
||||
ORDER BY rf.name
|
||||
"""
|
||||
result = client.execute_sql_query(sql)
|
||||
if not result["rows"]:
|
||||
return {"filter_count": 0, "route_filters": []}
|
||||
|
||||
# Fetch members per filter
|
||||
out = []
|
||||
for filt in result["rows"]:
|
||||
member_sql = f"""
|
||||
SELECT
|
||||
dpt.tag AS tag,
|
||||
op.name AS operator,
|
||||
rfm.digits AS digits,
|
||||
rfm.precedence AS precedence
|
||||
FROM routefiltermember rfm
|
||||
LEFT OUTER JOIN dialplantag dpt ON rfm.fkdialplantag = dpt.pkid
|
||||
LEFT OUTER JOIN typeoperator op ON rfm.tkoperator = op.enum
|
||||
WHERE rfm.fkroutefilter = '{_esc(filt["pkid"])}'
|
||||
ORDER BY rfm.precedence
|
||||
"""
|
||||
members = client.execute_sql_query(member_sql)
|
||||
out.append({
|
||||
"name": filt.get("filter_name"),
|
||||
"clause": filt.get("clause"),
|
||||
"dial_plan": filt.get("dial_plan"),
|
||||
"members": members["rows"],
|
||||
})
|
||||
|
||||
return {"filter_count": len(out), "route_filters": out}
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Wildcard pattern matcher (better translation_chain)
|
||||
# ====================================================================
|
||||
|
||||
def _wildcard_to_regex(pattern: str) -> str:
|
||||
r"""Convert a CUCM dial-plan pattern to a Python regex.
|
||||
|
||||
CUCM wildcards:
|
||||
X any single digit (0-9)
|
||||
! one or more digits
|
||||
. terminator separator (after-dot digits get discarded by PreDot DDI)
|
||||
[0-9] character class (passes through to regex unchanged)
|
||||
*, # literal special-keypad symbols
|
||||
\+ literal + (escaped in CUCM)
|
||||
@ NANPA route filter — represented as `\d+` here (we don't model the filter)
|
||||
|
||||
We escape regex metachars except those CUCM uses literally as wildcards.
|
||||
"""
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(pattern):
|
||||
c = pattern[i]
|
||||
if c == "X":
|
||||
out.append(r"\d")
|
||||
elif c == "!":
|
||||
out.append(r"\d+")
|
||||
elif c == "@":
|
||||
# NANPA — would normally apply a route filter; treat as "any digits"
|
||||
out.append(r"\d+")
|
||||
elif c == ".":
|
||||
# Terminator separator — matches a literal dot if used in test;
|
||||
# but in pattern matching against a dialed number it has no
|
||||
# effect on what's matched. Treat as zero-width.
|
||||
pass
|
||||
elif c == "[":
|
||||
# Character class — copy through up to ]
|
||||
j = pattern.find("]", i)
|
||||
if j == -1:
|
||||
out.append(re.escape(c))
|
||||
i += 1
|
||||
continue
|
||||
out.append(pattern[i:j + 1])
|
||||
i = j
|
||||
elif c == "\\" and i + 1 < len(pattern):
|
||||
# Escaped literal — keep as literal
|
||||
out.append(re.escape(pattern[i + 1]))
|
||||
i += 1
|
||||
else:
|
||||
out.append(re.escape(c))
|
||||
i += 1
|
||||
return "^" + "".join(out) + "$"
|
||||
|
||||
|
||||
def _pattern_matches_number(pattern: str, number: str) -> bool:
|
||||
"""Test whether a CUCM dial pattern matches a number string."""
|
||||
try:
|
||||
regex = _wildcard_to_regex(pattern)
|
||||
return re.match(regex, number) is not None
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
|
||||
def resolve_pattern_destination(client: "AxlClient", pattern_pkid: str) -> dict | None:
|
||||
"""Given a route pattern pkid, return its destination route list / device.
|
||||
|
||||
Route patterns connect to their destination via `devicenumplanmap`:
|
||||
the route-list header device (device.tkclass='Route List') maps to the
|
||||
pattern (numplan with tkpatternusage=5) through this join table. Same
|
||||
table also connects phone lines, so we filter by tkclass to disambiguate.
|
||||
"""
|
||||
sql = f"""
|
||||
SELECT
|
||||
d.name AS destination_name,
|
||||
d.description AS destination_description,
|
||||
tc.name AS destination_class,
|
||||
d.pkid AS destination_pkid
|
||||
FROM devicenumplanmap dnm
|
||||
JOIN device d ON dnm.fkdevice = d.pkid
|
||||
JOIN typeclass tc ON d.tkclass = tc.enum
|
||||
WHERE dnm.fknumplan = '{_esc(pattern_pkid)}'
|
||||
AND tc.name IN ('Route List', 'Gateway', 'SIP Trunk', 'H323 Gateway',
|
||||
'Hunt List', 'CTI Route Point', 'Voicemail Port')
|
||||
"""
|
||||
result = client.execute_sql_query(sql)
|
||||
return result["rows"][0] if result["rows"] else None
|
||||
|
||||
|
||||
def list_digit_discard_instructions(client: "AxlClient") -> dict:
|
||||
"""Digit Discard Instructions catalog."""
|
||||
sql = """
|
||||
SELECT
|
||||
ddi.name AS name,
|
||||
ddi.description AS description,
|
||||
ddi.pkid AS pkid
|
||||
FROM digitdiscardinstruction ddi
|
||||
ORDER BY ddi.name
|
||||
"""
|
||||
result = client.execute_sql_query(sql)
|
||||
return {"ddi_count": result["row_count"], "digit_discard_instructions": result["rows"]}
|
||||
|
||||
|
||||
def translation_chain(client: "AxlClient", number: str, css_name: str | None = None) -> dict:
|
||||
"""Find dial patterns that match a number, with CUCM wildcard evaluation.
|
||||
|
||||
Two-stage approach:
|
||||
1. SQL fetches all patterns reachable from the given CSS (or all
|
||||
patterns if no CSS specified). This filters by partition membership.
|
||||
2. Python evaluates each pattern's CUCM wildcards (X, !, [0-9], @, etc.)
|
||||
against the number to find true matches, then sorts by pattern length
|
||||
(longest match wins in CUCM).
|
||||
|
||||
Caveats:
|
||||
- The @-wildcard matches "any digits" rather than applying a route filter.
|
||||
For accurate filter-aware matching, inspect the route filter via
|
||||
list_route_filters() and reason about it manually.
|
||||
- We don't model the dial-plan's interdigit timeout or T302 timer —
|
||||
every pattern is evaluated against the complete number.
|
||||
- For overlapping matches, longest-match-wins is approximated by sorting
|
||||
result by pattern length descending. CUCM's actual matcher uses pattern
|
||||
specificity (a sub-rule based on wildcard depth); for unambiguous
|
||||
patterns the two agree.
|
||||
"""
|
||||
css_filter = ""
|
||||
if css_name:
|
||||
css_filter = f"""
|
||||
AND rp.pkid IN (
|
||||
SELECT csm.fkroutepartition
|
||||
FROM callingsearchspacemember csm
|
||||
JOIN callingsearchspace css ON csm.fkcallingsearchspace = css.pkid
|
||||
WHERE css.name = '{_esc(css_name)}'
|
||||
)
|
||||
"""
|
||||
|
||||
# Pull every pattern in scope; we filter in Python with wildcard logic.
|
||||
sql = f"""
|
||||
SELECT
|
||||
np.dnorpattern AS pattern,
|
||||
tpu.name AS pattern_type,
|
||||
rp.name AS partition_name,
|
||||
np.callingpartytransformationmask AS calling_party_xform_mask,
|
||||
np.calledpartytransformationmask AS called_party_xform_mask,
|
||||
np.prefixdigitsout AS prefix_digits_out,
|
||||
ddi.name AS digit_discard_instructions,
|
||||
rf.name AS route_filter,
|
||||
np.description AS description
|
||||
FROM numplan np
|
||||
LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
|
||||
LEFT OUTER JOIN typepatternusage tpu ON np.tkpatternusage = tpu.enum
|
||||
LEFT OUTER JOIN digitdiscardinstruction ddi ON np.fkdigitdiscardinstruction = ddi.pkid
|
||||
LEFT OUTER JOIN routefilter rf ON np.fkroutefilter = rf.pkid
|
||||
WHERE np.tkpatternusage IN (3, 5, 7)
|
||||
AND np.dnorpattern IS NOT NULL
|
||||
{css_filter}
|
||||
"""
|
||||
result = client.execute_sql_query(sql)
|
||||
|
||||
matches = []
|
||||
for row in result["rows"]:
|
||||
pattern = row.get("pattern") or ""
|
||||
if _pattern_matches_number(pattern, number):
|
||||
matches.append({**row, "_match_specificity": len(pattern)})
|
||||
|
||||
# Longest-match-first (CUCM's specificity heuristic, simplified)
|
||||
matches.sort(key=lambda m: m["_match_specificity"], reverse=True)
|
||||
|
||||
return {
|
||||
"number": number,
|
||||
"css_name": css_name,
|
||||
"candidates_evaluated": result["row_count"],
|
||||
"match_count": len(matches),
|
||||
"matches": matches,
|
||||
"_note": (
|
||||
"Wildcards evaluated: X, !, [0-9], @, \\+. "
|
||||
"@-pattern matches any digit string (route filter constraints not applied). "
|
||||
"Longest-match-wins is approximated by pattern length; CUCM uses pattern "
|
||||
"specificity (wildcard depth). For most clusters these agree."
|
||||
),
|
||||
}
|
||||
569
src/mcp_cucm_axl/server.py
Normal file
569
src/mcp_cucm_axl/server.py
Normal file
@ -0,0 +1,569 @@
|
||||
"""FastMCP server: read-only CUCM 15 AXL with route-plan focus.
|
||||
|
||||
Tool surface:
|
||||
- Foundational: axl_sql, axl_describe_table, axl_list_tables, axl_version,
|
||||
cache_stats, cache_clear
|
||||
- Route plan: route_partitions, route_calling_search_spaces, route_patterns,
|
||||
route_inspect_pattern, route_lists_and_groups, route_translation_chain,
|
||||
route_digit_discard_instructions
|
||||
|
||||
Prompts (schema-grounded via the sibling cisco-docs index):
|
||||
- route_plan_overview
|
||||
- investigate_pattern
|
||||
- audit_routing
|
||||
- cucm_sql_help
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from importlib.metadata import version as _pkg_version
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastmcp import FastMCP
|
||||
from platformdirs import user_cache_dir
|
||||
|
||||
from . import route_plan
|
||||
from .cache import AxlCache
|
||||
from .client import AxlClient
|
||||
from .docs_loader import DocsIndex
|
||||
|
||||
|
||||
# ---- Module-level singletons, initialized in main() ----
|
||||
_cache: AxlCache | None = None
|
||||
_axl: AxlClient | None = None
|
||||
_docs: DocsIndex | None = None
|
||||
|
||||
|
||||
mcp = FastMCP("CUCM AXL (read-only)")
|
||||
|
||||
|
||||
def _client() -> AxlClient:
|
||||
if _axl is None:
|
||||
raise RuntimeError("AXL client not initialized — server bootstrap failed.")
|
||||
return _axl
|
||||
|
||||
|
||||
def _docs_or_empty_msg() -> str:
|
||||
return (
|
||||
"_The cisco-docs index is not loaded. Set CISCO_DOCS_INDEX_PATH or "
|
||||
"ensure /home/rpm/bingham/docs/src/assets/.cisco-docs-index/ exists. "
|
||||
"You can also use the sibling `cisco-docs` MCP server's `search_docs` "
|
||||
"tool for live semantic search._"
|
||||
)
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Foundational tools
|
||||
# ====================================================================
|
||||
|
||||
@mcp.tool
|
||||
def axl_version() -> dict:
|
||||
"""Return CUCM cluster version. Sanity check that AXL is reachable.
|
||||
|
||||
Cached for 1 hour — version doesn't change between cluster upgrades.
|
||||
"""
|
||||
return _client().get_ccm_version()
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def axl_sql(query: str) -> dict:
|
||||
"""Execute a SELECT (or WITH CTE) against the CUCM Informix data dictionary.
|
||||
|
||||
Read-only by structural guarantee: the server never exposes executeSQLUpdate
|
||||
or any add/update/remove AXL methods. Queries are also client-side validated
|
||||
to start with SELECT or WITH and to contain no write keywords.
|
||||
|
||||
Args:
|
||||
query: A single SQL SELECT statement. Trailing semicolon optional.
|
||||
|
||||
Returns:
|
||||
dict with row_count, rows (list of column→value dicts), and the
|
||||
cleaned query as it was sent. Includes _cache: hit|miss for diagnostics.
|
||||
"""
|
||||
return _client().execute_sql_query(query)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def axl_list_tables(pattern: str | None = None) -> dict:
|
||||
"""List Informix tables in the CUCM database.
|
||||
|
||||
Args:
|
||||
pattern: Optional LIKE pattern (% wildcards). e.g. "route%" finds
|
||||
routelist, routegroup, routepartition, routefilter, etc.
|
||||
"""
|
||||
return _client().list_informix_tables(pattern)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def axl_describe_table(table_name: str) -> dict:
|
||||
"""Describe an Informix table's columns: name, type, length, nullability.
|
||||
|
||||
Use this BEFORE writing axl_sql queries against an unfamiliar table.
|
||||
Combine with the cisco-docs MCP's search_docs("data dictionary <table>")
|
||||
for human-authored descriptions of what each column means.
|
||||
"""
|
||||
return _client().describe_informix_table(table_name)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def cache_stats() -> dict:
|
||||
"""Cache statistics: total entries, live entries, breakdown by method."""
|
||||
if _cache is None:
|
||||
return {"error": "Cache not initialized"}
|
||||
return _cache.stats()
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def cache_clear(method_pattern: str | None = None) -> dict:
|
||||
"""Clear cache entries.
|
||||
|
||||
Args:
|
||||
method_pattern: Optional method-name pattern (% wildcards). If omitted,
|
||||
clears the entire cache. Use after a known config change to force
|
||||
fresh queries.
|
||||
"""
|
||||
if _cache is None:
|
||||
return {"error": "Cache not initialized"}
|
||||
deleted = _cache.clear(method_pattern)
|
||||
return {"deleted_entries": deleted, "method_pattern": method_pattern}
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Route plan tools
|
||||
# ====================================================================
|
||||
|
||||
@mcp.tool
|
||||
def route_partitions() -> dict:
|
||||
"""All route partitions, with pattern count and CSS member count per partition.
|
||||
|
||||
A partition groups together patterns (DNs, route patterns, translations) for
|
||||
access control. CSSs include partitions in a specific order; longest match
|
||||
in the first reachable partition wins.
|
||||
"""
|
||||
return route_plan.list_partitions(_client())
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def route_calling_search_spaces(name: str | None = None) -> dict:
|
||||
"""Calling Search Spaces with their ordered partition lists.
|
||||
|
||||
Args:
|
||||
name: Optional CSS name to fetch one specific CSS. If None, returns all.
|
||||
|
||||
The order of partitions inside a CSS is the search order — CUCM walks the
|
||||
partitions top-down, longest-match within each, and routes via the first
|
||||
matching pattern.
|
||||
"""
|
||||
return route_plan.list_calling_search_spaces(_client(), name)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def route_patterns(
|
||||
kind: str | None = None,
|
||||
partition: str | None = None,
|
||||
filter: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> dict:
|
||||
"""The Route Plan Report — patterns with their transformations.
|
||||
|
||||
Args:
|
||||
kind: Pattern kind filter. One of: directory_number, translation, route,
|
||||
conference, voicemail, hunt_pilot, call_pickup_group, park_code,
|
||||
directed_pickup, message_waiting, device_template. Default: all kinds.
|
||||
partition: Filter by partition name (exact match).
|
||||
filter: Substring match against the pattern text (e.g. "9.@" or "+1").
|
||||
limit: Max rows. Default 500.
|
||||
|
||||
Each row includes calling/called party transformation masks, prefix digits,
|
||||
digit discard instructions — the full transformation profile.
|
||||
"""
|
||||
return route_plan.list_patterns(
|
||||
_client(),
|
||||
kind=kind,
|
||||
partition=partition,
|
||||
filter_substring=filter,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def route_inspect_pattern(pattern: str, partition: str | None = None) -> dict:
|
||||
"""Deep dive on a single pattern.
|
||||
|
||||
Returns:
|
||||
- The pattern row itself with all transformations
|
||||
- Reverse CSS lookup: which calling search spaces include this pattern's
|
||||
partition (i.e., which phones/devices can reach this pattern)
|
||||
- Routing target: route list or device this pattern points to
|
||||
|
||||
Args:
|
||||
pattern: The pattern text (e.g. "9.@", "+1.[2-9]XX[2-9]XXXXXX", "1001").
|
||||
partition: Disambiguate when the same pattern exists in multiple partitions.
|
||||
"""
|
||||
return route_plan.inspect_pattern(_client(), pattern, partition)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def route_lists_and_groups(name: str | None = None) -> dict:
|
||||
"""Route lists with their ordered route groups and member devices.
|
||||
|
||||
Route lists are how route patterns reach the outside world: a route pattern
|
||||
points to a route list, the route list contains an ordered list of route
|
||||
groups, and each route group contains an ordered list of devices (gateways,
|
||||
SIP trunks, etc.). Top-down with failover.
|
||||
|
||||
Args:
|
||||
name: Optional route list name to fetch one specific list.
|
||||
"""
|
||||
return route_plan.list_route_lists_and_groups(_client(), name)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def route_translation_chain(number: str, css_name: str | None = None) -> dict:
|
||||
"""Find candidate patterns that might match a given number from a given CSS.
|
||||
|
||||
Best-effort: literal/prefix matching only. CUCM's actual matcher evaluates
|
||||
wildcards (X, !, [0-9], etc.) and selects longest match. Use as a starting
|
||||
point for "where could this number be coming from?" investigations.
|
||||
|
||||
Args:
|
||||
number: The number to test (e.g. "9123456789", "+15551234567", "1001").
|
||||
css_name: Restrict candidates to patterns in partitions reachable from
|
||||
this CSS. If None, considers all patterns.
|
||||
"""
|
||||
return route_plan.translation_chain(_client(), number, css_name)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def route_digit_discard_instructions() -> dict:
|
||||
"""List all Digit Discard Instructions (DDIs).
|
||||
|
||||
DDIs are named rules like "PreDot" or "10-10-Dialing" that strip digits
|
||||
from called-party numbers before they leave the cluster. Patterns reference
|
||||
DDIs via fkdigitdiscardinstruction.
|
||||
"""
|
||||
return route_plan.list_digit_discard_instructions(_client())
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def route_device_pool_route_groups(device_pool_name: str | None = None) -> dict:
|
||||
"""How each device pool resolves Local Route Group placeholders to actual gateways.
|
||||
|
||||
Closes the loop on Local Route Group routing: when a route list step references
|
||||
a "named placeholder" route group with no static devices, this is where the
|
||||
actual gateway is resolved per-device-pool at call time.
|
||||
|
||||
Args:
|
||||
device_pool_name: Optional. If given, return only that DP's mappings.
|
||||
"""
|
||||
return route_plan.list_device_pool_route_groups(_client(), device_pool_name)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def route_devices_using_css(css_name: str) -> dict:
|
||||
"""Impact analysis: every reference to a CSS across the schema.
|
||||
|
||||
Use before changing or removing a CSS to find all dependent devices, lines,
|
||||
translation patterns, route lists, voicemail pilots, etc. Cross-references
|
||||
the major fkcallingsearchspace* columns across the data dictionary.
|
||||
|
||||
Args:
|
||||
css_name: The exact CSS name (case-sensitive).
|
||||
"""
|
||||
return route_plan.find_devices_using_css(_client(), css_name)
|
||||
|
||||
|
||||
@mcp.tool
|
||||
def route_filters(name: str | None = None) -> dict:
|
||||
"""List route filters with their composition rules.
|
||||
|
||||
Route filters compose with @-pattern (NANPA) route patterns to constrain
|
||||
which calls match — e.g., "AREA-CODE == 208" narrows a `9.@` pattern to
|
||||
in-state calls. Each filter has an ordered list of (digit, operator, tag)
|
||||
member clauses.
|
||||
|
||||
Args:
|
||||
name: Optional. If given, return only the named filter.
|
||||
"""
|
||||
return route_plan.list_route_filters(_client(), name)
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Prompts — schema-grounded conversation seeds
|
||||
# ====================================================================
|
||||
|
||||
_ROUTE_KEYWORDS = [
|
||||
"route plan", "route pattern", "translation pattern",
|
||||
"calling search space", "partition", "transformation",
|
||||
"digit discard", "numplan", "routepartition",
|
||||
]
|
||||
|
||||
_AUDIT_PROMPTS = {
|
||||
"route_plan_overview": [
|
||||
"route plan", "route pattern", "calling search space", "partition",
|
||||
],
|
||||
"translations": [
|
||||
"translation pattern", "called party transformation",
|
||||
"calling party transformation", "digit discard",
|
||||
],
|
||||
"css_partitions": [
|
||||
"calling search space", "partition", "css",
|
||||
],
|
||||
"transformations": [
|
||||
"called party transformation", "calling party transformation",
|
||||
"transformation mask", "prefix digits",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def route_plan_overview() -> str:
|
||||
"""Snapshot of the cluster's routing setup, with schema reference embedded.
|
||||
|
||||
Use this when you want to start a fresh route-plan audit conversation.
|
||||
"""
|
||||
chunks = []
|
||||
if _docs is not None:
|
||||
chunks = _docs.find(_ROUTE_KEYWORDS, max_chunks=5, max_chars_per_chunk=1000)
|
||||
schema_block = (
|
||||
_docs.format_chunks_for_prompt(chunks) if _docs else _docs_or_empty_msg()
|
||||
)
|
||||
|
||||
return f"""# CUCM Route Plan Overview
|
||||
|
||||
You are auditing the routing configuration of a CUCM 15 cluster via the
|
||||
`mcp-cucm-axl` MCP server (read-only). Begin by gathering a high-level
|
||||
snapshot, then drill in where anything looks wrong or surprising.
|
||||
|
||||
## Suggested first calls (in order)
|
||||
|
||||
1. `axl_version()` — confirm cluster reachability + version
|
||||
2. `route_partitions()` — partition catalog with member counts
|
||||
3. `route_calling_search_spaces()` — CSS list with ordered partitions
|
||||
4. `route_patterns(kind="route")` — outbound route patterns
|
||||
5. `route_patterns(kind="translation")` — translation patterns
|
||||
6. `route_lists_and_groups()` — route list → route group → device chain
|
||||
7. `route_digit_discard_instructions()` — DDI catalog
|
||||
|
||||
## What to look for in your initial summary
|
||||
|
||||
- **Partition sprawl**: > ~30 partitions usually indicates accumulated
|
||||
legacy config. Note any with zero patterns or zero CSS membership.
|
||||
- **CSS-partition asymmetry**: partitions not reachable from any CSS are
|
||||
effectively dead.
|
||||
- **Pattern density**: which partitions hold the bulk of route/translation
|
||||
patterns? That's where the dial plan logic lives.
|
||||
- **Transformation patterns**: cgpn/cdpn masks and DDIs that look unusual
|
||||
or undocumented.
|
||||
- **Route list depth**: route lists with one route group are fine; with many,
|
||||
understand the failover order.
|
||||
|
||||
## Reference: CUCM data dictionary (route plan)
|
||||
|
||||
{schema_block}
|
||||
|
||||
Now run the calls above and produce a written audit summary.
|
||||
"""
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def investigate_pattern(pattern: str, partition: str | None = None) -> str:
|
||||
"""Deep-dive seed for one specific pattern. Schema chunks embedded."""
|
||||
chunks = []
|
||||
if _docs is not None:
|
||||
chunks = _docs.find(
|
||||
["numplan", "transformation", "translation pattern", "route pattern"],
|
||||
max_chunks=4,
|
||||
max_chars_per_chunk=900,
|
||||
)
|
||||
schema_block = (
|
||||
_docs.format_chunks_for_prompt(chunks) if _docs else _docs_or_empty_msg()
|
||||
)
|
||||
partition_clause = f" in partition `{partition}`" if partition else ""
|
||||
|
||||
return f"""# Investigate Pattern: `{pattern}`{partition_clause}
|
||||
|
||||
Walk the user through this pattern in detail.
|
||||
|
||||
## Suggested calls
|
||||
|
||||
1. `route_inspect_pattern(pattern={pattern!r}{f', partition={partition!r}' if partition else ''})`
|
||||
— pattern detail, transformations, target route list/device, reverse CSS lookup
|
||||
2. `route_translation_chain(number=<sample number>)` — what other patterns
|
||||
would compete for matches if this pattern matched a real call
|
||||
3. If it's a route pattern with a route list target, follow with
|
||||
`route_lists_and_groups(name=<route list name>)`
|
||||
|
||||
## What to report
|
||||
|
||||
- **Type**: directory number / route / translation / hunt pilot / etc.
|
||||
- **Transformations applied**:
|
||||
- Called party transformation mask
|
||||
- Calling party transformation mask
|
||||
- Prefix digits
|
||||
- Digit discard instructions
|
||||
- **Routing target**: where does the call ultimately go?
|
||||
- **Who can reach it**: which CSSs include this pattern's partition? Which
|
||||
device-pool/phone classes use those CSSs?
|
||||
- **Anything anomalous**: missing description, undocumented transformations,
|
||||
patterns that shadow each other, etc.
|
||||
|
||||
## Reference: CUCM data dictionary
|
||||
|
||||
{schema_block}
|
||||
"""
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def audit_routing(focus: str = "full") -> str:
|
||||
"""Comprehensive routing audit walkthrough.
|
||||
|
||||
Args:
|
||||
focus: One of "full", "translations", "css_partitions", "transformations",
|
||||
"route_lists". Tunes which schema chunks get embedded.
|
||||
"""
|
||||
keyword_set = _AUDIT_PROMPTS.get(focus, _ROUTE_KEYWORDS)
|
||||
chunks = []
|
||||
if _docs is not None:
|
||||
chunks = _docs.find(keyword_set, max_chunks=6, max_chars_per_chunk=900)
|
||||
schema_block = (
|
||||
_docs.format_chunks_for_prompt(chunks) if _docs else _docs_or_empty_msg()
|
||||
)
|
||||
|
||||
return f"""# CUCM Routing Audit — focus: `{focus}`
|
||||
|
||||
Conduct a focused audit of the cluster's routing configuration. Goal: produce
|
||||
an actionable findings report — not just a description of the config.
|
||||
|
||||
## Audit checklist
|
||||
|
||||
### Partitions and access control
|
||||
- [ ] Are there partitions with zero patterns? (legacy/orphaned)
|
||||
- [ ] Are there partitions not referenced by any CSS? (unreachable)
|
||||
- [ ] Does the partition naming convention reflect actual scope?
|
||||
|
||||
### Calling Search Spaces
|
||||
- [ ] CSS-to-CSS comparison: any near-duplicates that differ only in order?
|
||||
- [ ] CSSs that include partitions in surprising orders (longest-match implications)
|
||||
- [ ] CSSs that are referenced by zero devices (use axl_sql against device table)
|
||||
|
||||
### Translation patterns
|
||||
- [ ] What does each translation pattern actually transform? Any with no
|
||||
transformation that exist purely for partition routing?
|
||||
- [ ] Calling-party transformations applied at translation: are they
|
||||
documented? Why is the calling number being rewritten?
|
||||
- [ ] Translation chains: do any translations route into partitions where
|
||||
another translation will match again? (chains can be intentional but
|
||||
obscure caller-ID and routing logic)
|
||||
|
||||
### Route patterns
|
||||
- [ ] Wildcard breadth: any `9.@` (NANPA at-sign) patterns? Are they intentional?
|
||||
- [ ] Block patterns: which patterns have `block_enabled` set? What are they
|
||||
blocking and why?
|
||||
- [ ] Patterns with no description — flag for documentation.
|
||||
|
||||
### Transformations (called party / calling party)
|
||||
- [ ] Digit discard instructions: which DDIs are referenced? Are any DDIs
|
||||
defined but never used?
|
||||
- [ ] Prefix-digits-out: any unusual prefixes (international, special service)?
|
||||
- [ ] Calling-party masks that hide internal extensions on outbound calls.
|
||||
|
||||
### Route lists and groups
|
||||
- [ ] Route lists with only one route group: simple, fine.
|
||||
- [ ] Route lists with many: walk the failover order, confirm it's intentional.
|
||||
- [ ] Route groups containing devices that are unregistered or disabled.
|
||||
|
||||
## Reference: CUCM data dictionary
|
||||
|
||||
{schema_block}
|
||||
|
||||
Run the relevant tool calls now and produce a structured findings report
|
||||
with category headers, observation, severity (info/warning/error), and
|
||||
recommended action where applicable.
|
||||
"""
|
||||
|
||||
|
||||
@mcp.prompt
|
||||
def cucm_sql_help(question: str) -> str:
|
||||
"""Catch-all prompt for arbitrary CUCM SQL questions. Includes schema chunks."""
|
||||
keywords = [w for w in question.lower().split() if len(w) > 3][:8]
|
||||
chunks = []
|
||||
if _docs is not None and keywords:
|
||||
chunks = _docs.find(keywords, max_chunks=5, max_chars_per_chunk=900)
|
||||
schema_block = (
|
||||
_docs.format_chunks_for_prompt(chunks) if _docs else _docs_or_empty_msg()
|
||||
)
|
||||
|
||||
return f"""# CUCM SQL Question
|
||||
|
||||
The user asks: **{question}**
|
||||
|
||||
## How to approach this
|
||||
|
||||
1. If you don't recognize the relevant tables, call `axl_list_tables(pattern=...)`
|
||||
with a substring guess (e.g., "route%", "device%", "user%").
|
||||
2. Run `axl_describe_table(<table_name>)` for the candidate tables to see
|
||||
exact column names and types.
|
||||
3. If the schema chunks below already answer the question, draft the SQL
|
||||
directly. If not, also invoke the `cisco-docs` MCP server's `search_docs`
|
||||
tool with a relevant query (e.g., search_docs("data dictionary table for X")).
|
||||
4. Compose the SELECT, run it via `axl_sql(query=...)`.
|
||||
5. Summarize the result for the user — counts, anomalies, and what you'd
|
||||
recommend doing about them.
|
||||
|
||||
## Possibly relevant schema chunks
|
||||
|
||||
{schema_block}
|
||||
|
||||
Now answer the question.
|
||||
"""
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Bootstrap
|
||||
# ====================================================================
|
||||
|
||||
def _banner() -> None:
|
||||
try:
|
||||
v = _pkg_version("mcp-cucm-axl")
|
||||
except Exception:
|
||||
v = "0.1.0"
|
||||
axl_url = os.environ.get("AXL_URL", "(unset)")
|
||||
print(f"[mcp-cucm-axl] v{v} starting", file=sys.stderr, flush=True)
|
||||
print(f"[mcp-cucm-axl] AXL_URL={axl_url}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
global _cache, _axl, _docs
|
||||
|
||||
# Load .env from the project directory (where the user runs uv from)
|
||||
cwd_env = Path.cwd() / ".env"
|
||||
if cwd_env.exists():
|
||||
load_dotenv(cwd_env)
|
||||
|
||||
_banner()
|
||||
|
||||
cache_dir = Path(
|
||||
os.environ.get("AXL_CACHE_DIR")
|
||||
or (Path(user_cache_dir("mcp-cucm-axl")) / "responses")
|
||||
)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
ttl = int(os.environ.get("AXL_CACHE_TTL", "3600"))
|
||||
_cache = AxlCache(cache_dir / "axl_responses.sqlite", default_ttl=ttl)
|
||||
print(
|
||||
f"[mcp-cucm-axl] cache: {_cache.db_path} (ttl={ttl}s)",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
_axl = AxlClient(_cache)
|
||||
_docs = DocsIndex.load() # may be None; prompts handle gracefully
|
||||
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
61
src/mcp_cucm_axl/sql_validator.py
Normal file
61
src/mcp_cucm_axl/sql_validator.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""Defense-in-depth: ensure queries we send to executeSQLQuery are SELECT-only.
|
||||
|
||||
CUCM's AXL service rejects writes via executeSQLQuery server-side (writes go
|
||||
through a separate executeSQLUpdate method that we never expose). This client-
|
||||
side check exists to:
|
||||
1. Give the LLM a fast, clear error before a SOAP round-trip.
|
||||
2. Make read-only intent visible at the boundary, not implicit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
_COMMENT_BLOCK = re.compile(r"/\*.*?\*/", re.DOTALL)
|
||||
_COMMENT_LINE = re.compile(r"--[^\n]*")
|
||||
_FORBIDDEN = {
|
||||
"INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER",
|
||||
"TRUNCATE", "GRANT", "REVOKE", "MERGE", "REPLACE", "RENAME",
|
||||
"EXEC", "EXECUTE", "CALL", "ATTACH", "DETACH",
|
||||
}
|
||||
_WORD_RE = re.compile(r"\b([A-Za-z_]+)\b")
|
||||
|
||||
|
||||
class SqlValidationError(ValueError):
|
||||
"""Raised when a query is not a safe read-only SELECT/WITH."""
|
||||
|
||||
|
||||
def validate_select(query: str) -> str:
|
||||
"""Return the cleaned query, or raise SqlValidationError.
|
||||
|
||||
Accepts SELECT and WITH (CTEs that ultimately return SELECT). Rejects
|
||||
anything else, and any query containing forbidden keywords as standalone
|
||||
tokens.
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
raise SqlValidationError("Query is empty.")
|
||||
|
||||
stripped = _COMMENT_BLOCK.sub(" ", query)
|
||||
stripped = _COMMENT_LINE.sub(" ", stripped).strip().rstrip(";").strip()
|
||||
if not stripped:
|
||||
raise SqlValidationError("Query is empty after stripping comments.")
|
||||
|
||||
upper_tokens = [t.upper() for t in _WORD_RE.findall(stripped)]
|
||||
if not upper_tokens:
|
||||
raise SqlValidationError("Query contains no SQL keywords.")
|
||||
|
||||
first = upper_tokens[0]
|
||||
if first not in {"SELECT", "WITH"}:
|
||||
raise SqlValidationError(
|
||||
f"Only SELECT and WITH are permitted; query starts with {first!r}."
|
||||
)
|
||||
|
||||
forbidden_hits = sorted(set(upper_tokens) & _FORBIDDEN)
|
||||
if forbidden_hits:
|
||||
raise SqlValidationError(
|
||||
f"Forbidden keyword(s) present: {', '.join(forbidden_hits)}. "
|
||||
f"This server is read-only."
|
||||
)
|
||||
|
||||
return stripped
|
||||
155
src/mcp_cucm_axl/wsdl_loader.py
Normal file
155
src/mcp_cucm_axl/wsdl_loader.py
Normal file
@ -0,0 +1,155 @@
|
||||
"""Resolve the AXLAPI WSDL for CUCM 15.
|
||||
|
||||
Resolution chain:
|
||||
1. AXL_WSDL_PATH env var (explicit override) — use it
|
||||
2. ~/.cache/mcp-cucm-axl/wsdl/15.0/AXLAPI.wsdl — use cached copy
|
||||
3. Auto-extract `schema/15.0/` from a Cisco AXL Toolkit zip:
|
||||
- $AXL_WSDL_ZIP if set
|
||||
- ./axlsqltoolkit.zip in the current working directory
|
||||
4. Raise with a clear pointer to the AXL toolkit download in the CUCM admin UI
|
||||
|
||||
The full AXL toolkit ships as three files:
|
||||
AXLAPI.wsdl, AXLEnums.xsd, AXLSoap.xsd
|
||||
all in the same directory. Zeep follows the schema imports from the WSDL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from platformdirs import user_cache_dir
|
||||
|
||||
|
||||
WSDL_FILES = ("AXLAPI.wsdl", "AXLEnums.xsd", "AXLSoap.xsd")
|
||||
WSDL_VERSION = "15.0"
|
||||
|
||||
|
||||
def cache_wsdl_dir(version: str = WSDL_VERSION) -> Path:
|
||||
"""Return ~/.cache/mcp-cucm-axl/wsdl/<version>/."""
|
||||
return Path(user_cache_dir("mcp-cucm-axl")) / "wsdl" / version
|
||||
|
||||
|
||||
def _has_complete_wsdl(directory: Path) -> bool:
|
||||
return all((directory / fname).exists() for fname in WSDL_FILES)
|
||||
|
||||
|
||||
def _try_extract_from_zip(version: str = WSDL_VERSION) -> Path | None:
|
||||
"""Look for a Cisco AXL Toolkit zip and extract `schema/<version>/` from it.
|
||||
|
||||
Search order:
|
||||
1. $AXL_WSDL_ZIP env var
|
||||
2. ./axlsqltoolkit.zip in current working directory
|
||||
|
||||
Returns the cache directory path if successful, None if no zip found
|
||||
or extraction failed.
|
||||
"""
|
||||
candidates: list[Path] = []
|
||||
if env_zip := os.environ.get("AXL_WSDL_ZIP"):
|
||||
candidates.append(Path(env_zip).expanduser())
|
||||
candidates.append(Path.cwd() / "axlsqltoolkit.zip")
|
||||
|
||||
zip_path = next((p for p in candidates if p.exists() and p.is_file()), None)
|
||||
if zip_path is None:
|
||||
return None
|
||||
|
||||
target = cache_wsdl_dir(version)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(
|
||||
f"[mcp-cucm-axl] extracting AXL schema {version} from {zip_path}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
extracted = []
|
||||
for fname in WSDL_FILES:
|
||||
member = f"schema/{version}/{fname}"
|
||||
if member not in zf.namelist():
|
||||
print(
|
||||
f"[mcp-cucm-axl] zip missing member: {member}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
data = zf.read(member)
|
||||
(target / fname).write_bytes(data)
|
||||
extracted.append(fname)
|
||||
print(
|
||||
f"[mcp-cucm-axl] extracted {len(extracted)} files into {target}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return target
|
||||
except (zipfile.BadZipFile, OSError) as e:
|
||||
print(f"[mcp-cucm-axl] zip extraction failed: {e}", file=sys.stderr, flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_wsdl_path() -> Path:
|
||||
"""Return the path to AXLAPI.wsdl, or raise SystemExit with setup help."""
|
||||
explicit = os.environ.get("AXL_WSDL_PATH")
|
||||
if explicit:
|
||||
p = Path(explicit).expanduser().resolve()
|
||||
if not p.exists():
|
||||
raise SystemExit(f"AXL_WSDL_PATH points to nonexistent file: {p}")
|
||||
if p.is_dir():
|
||||
p = p / "AXLAPI.wsdl"
|
||||
if not p.exists():
|
||||
raise SystemExit(
|
||||
f"AXL_WSDL_PATH is a directory but AXLAPI.wsdl is not inside it: {p.parent}"
|
||||
)
|
||||
if not _has_complete_wsdl(p.parent):
|
||||
missing = [f for f in WSDL_FILES if not (p.parent / f).exists()]
|
||||
print(
|
||||
f"[mcp-cucm-axl] warning: WSDL dir missing {missing} alongside {p.name}; "
|
||||
f"zeep may fail to resolve schema imports.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return p
|
||||
|
||||
cache_dir = cache_wsdl_dir()
|
||||
if _has_complete_wsdl(cache_dir):
|
||||
return cache_dir / "AXLAPI.wsdl"
|
||||
|
||||
# Auto-extract from a Cisco AXL Toolkit zip if present
|
||||
extracted = _try_extract_from_zip()
|
||||
if extracted and _has_complete_wsdl(extracted):
|
||||
return extracted / "AXLAPI.wsdl"
|
||||
|
||||
_bootstrap_help(cache_dir)
|
||||
raise SystemExit(2) # never reached; _bootstrap_help raises
|
||||
|
||||
|
||||
def _bootstrap_help(cache_dir: Path) -> None:
|
||||
"""Print setup instructions and exit. Never returns."""
|
||||
msg = [
|
||||
"",
|
||||
"AXL WSDL not found. To bootstrap, do one of:",
|
||||
"",
|
||||
" Option A — drop axlsqltoolkit.zip into the project directory:",
|
||||
f" cp /path/to/axlsqltoolkit.zip {Path.cwd()}/",
|
||||
" # Auto-extracts schema/15.0/ on next launch",
|
||||
"",
|
||||
" Option B — point at a zip elsewhere:",
|
||||
" export AXL_WSDL_ZIP=/path/to/axlsqltoolkit.zip",
|
||||
"",
|
||||
" Option C — explicit WSDL file path:",
|
||||
" export AXL_WSDL_PATH=/path/to/schema/15.0/AXLAPI.wsdl",
|
||||
"",
|
||||
" Option D — drop the three schema files into the cache:",
|
||||
f" mkdir -p {cache_dir}",
|
||||
" # Copy AXLAPI.wsdl, AXLEnums.xsd, AXLSoap.xsd into that directory",
|
||||
"",
|
||||
"To obtain the AXL toolkit:",
|
||||
" 1. Sign in to CUCM admin",
|
||||
" 2. Application > Plugins > Find > download 'Cisco AXL Toolkit'",
|
||||
" 3. Use the resulting axlsqltoolkit.zip with any option above",
|
||||
"",
|
||||
]
|
||||
raise SystemExit("\n".join(msg))
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
87
tests/test_cache.py
Normal file
87
tests/test_cache.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Tests for the SQLite TTL cache."""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_cucm_axl.cache import AxlCache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache(tmp_path: Path) -> AxlCache:
|
||||
return AxlCache(tmp_path / "test.sqlite", default_ttl=60)
|
||||
|
||||
|
||||
def test_set_and_get(cache: AxlCache):
|
||||
cache.set("getCCMVersion", {}, {"version": "15.0.1"})
|
||||
assert cache.get("getCCMVersion", {}) == {"version": "15.0.1"}
|
||||
|
||||
|
||||
def test_miss_returns_none(cache: AxlCache):
|
||||
assert cache.get("nonexistent", {"x": 1}) is None
|
||||
|
||||
|
||||
def test_kwargs_order_independent(cache: AxlCache):
|
||||
cache.set("listPhone", {"name": "SEP1", "limit": 10}, {"rows": ["p1"]})
|
||||
# Different order should still hit
|
||||
assert cache.get("listPhone", {"limit": 10, "name": "SEP1"}) == {"rows": ["p1"]}
|
||||
|
||||
|
||||
def test_different_args_different_keys(cache: AxlCache):
|
||||
cache.set("listPhone", {"name": "SEP1"}, {"rows": ["a"]})
|
||||
cache.set("listPhone", {"name": "SEP2"}, {"rows": ["b"]})
|
||||
assert cache.get("listPhone", {"name": "SEP1"}) == {"rows": ["a"]}
|
||||
assert cache.get("listPhone", {"name": "SEP2"}) == {"rows": ["b"]}
|
||||
|
||||
|
||||
def test_expired_entries_not_returned(tmp_path: Path):
|
||||
c = AxlCache(tmp_path / "ttl.sqlite", default_ttl=60)
|
||||
c.set("foo", {}, {"x": 1}, ttl=1)
|
||||
time.sleep(1.1)
|
||||
assert c.get("foo", {}) is None
|
||||
|
||||
|
||||
def test_ttl_zero_disables_caching(tmp_path: Path):
|
||||
c = AxlCache(tmp_path / "off.sqlite", default_ttl=0)
|
||||
c.set("foo", {}, {"x": 1})
|
||||
# default_ttl=0 means writes are no-ops
|
||||
assert c.get("foo", {}) is None
|
||||
|
||||
|
||||
def test_stats_reports_breakdown(cache: AxlCache):
|
||||
cache.set("listPhone", {}, {"x": 1})
|
||||
cache.set("listPhone", {"a": 1}, {"x": 2})
|
||||
cache.set("getCCMVersion", {}, {"v": "15"})
|
||||
|
||||
stats = cache.stats()
|
||||
assert stats["live_entries"] == 3
|
||||
assert stats["by_method"]["listPhone"] == 2
|
||||
assert stats["by_method"]["getCCMVersion"] == 1
|
||||
|
||||
|
||||
def test_clear_all(cache: AxlCache):
|
||||
cache.set("a", {}, "x")
|
||||
cache.set("b", {}, "y")
|
||||
deleted = cache.clear()
|
||||
assert deleted == 2
|
||||
assert cache.stats()["live_entries"] == 0
|
||||
|
||||
|
||||
def test_clear_by_pattern(cache: AxlCache):
|
||||
cache.set("listPhone", {}, "p")
|
||||
cache.set("listLine", {}, "l")
|
||||
cache.set("getCCMVersion", {}, "v")
|
||||
deleted = cache.clear("list*")
|
||||
assert deleted == 2
|
||||
assert cache.get("getCCMVersion", {}) == "v"
|
||||
|
||||
|
||||
def test_purge_expired(tmp_path: Path):
|
||||
c = AxlCache(tmp_path / "p.sqlite", default_ttl=60)
|
||||
c.set("a", {}, "x", ttl=1)
|
||||
c.set("b", {}, "y", ttl=60)
|
||||
time.sleep(1.1)
|
||||
purged = c.purge_expired()
|
||||
assert purged == 1
|
||||
assert c.stats()["live_entries"] == 1
|
||||
99
tests/test_docs_loader.py
Normal file
99
tests/test_docs_loader.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""Tests for the docs index loader (chunk filtering for prompt enrichment)."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_cucm_axl.docs_loader import DocsIndex
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_index(tmp_path: Path) -> Path:
|
||||
chunks = [
|
||||
{
|
||||
"id": "cucm::v15::admin::Route-Plan-Overview::0",
|
||||
"text": "The route plan defines how calls are routed through the cluster.",
|
||||
"heading_path": ["Route Plan Overview"],
|
||||
"source_path": str(tmp_path / "fake.md"),
|
||||
"product": "cucm",
|
||||
"version": "v15",
|
||||
"doc": "admin",
|
||||
},
|
||||
{
|
||||
"id": "cucm::v15::admin::Translation-Patterns::0",
|
||||
"text": "Translation patterns rewrite digits before routing.",
|
||||
"heading_path": ["Call Routing", "Translation Patterns"],
|
||||
"source_path": str(tmp_path / "fake.md"),
|
||||
"product": "cucm",
|
||||
"version": "v15",
|
||||
"doc": "admin",
|
||||
},
|
||||
{
|
||||
"id": "cer::v15::admin::Caller-ID::0",
|
||||
"text": "Caller ID handling for emergency calls.",
|
||||
"heading_path": ["Caller ID"],
|
||||
"source_path": str(tmp_path / "fake.md"),
|
||||
"product": "cer",
|
||||
"version": "v15",
|
||||
"doc": "admin",
|
||||
},
|
||||
]
|
||||
(tmp_path / "chunks.jsonl").write_text(
|
||||
"\n".join(json.dumps(c) for c in chunks)
|
||||
)
|
||||
(tmp_path / "index_meta.json").write_text(
|
||||
json.dumps({"model_name": "test", "embedding_dim": 384, "products": ["cucm", "cer"]})
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_load_index(fake_index: Path):
|
||||
idx = DocsIndex.load(fake_index)
|
||||
assert idx is not None
|
||||
assert len(idx.chunks) == 3
|
||||
|
||||
|
||||
def test_load_missing_returns_none(tmp_path: Path):
|
||||
assert DocsIndex.load(tmp_path / "nope") is None
|
||||
|
||||
|
||||
def test_find_filters_by_product(fake_index: Path):
|
||||
idx = DocsIndex.load(fake_index)
|
||||
assert idx is not None
|
||||
cucm_only = idx.find(["caller"], product="cucm")
|
||||
assert all(c.get("doc") for c in cucm_only)
|
||||
cer_only = idx.find(["caller"], product="cer")
|
||||
assert any("Caller" in (c["heading_path"] or [""])[0] for c in cer_only)
|
||||
|
||||
|
||||
def test_find_scores_heading_higher_than_text(fake_index: Path):
|
||||
idx = DocsIndex.load(fake_index)
|
||||
assert idx is not None
|
||||
results = idx.find(["translation"], product="cucm")
|
||||
assert results
|
||||
# The chunk with "Translation Patterns" in heading should rank above
|
||||
# any other chunk that just mentions translation incidentally
|
||||
assert "Translation" in " ".join(results[0]["heading_path"] or [])
|
||||
|
||||
|
||||
def test_find_no_matches(fake_index: Path):
|
||||
idx = DocsIndex.load(fake_index)
|
||||
assert idx is not None
|
||||
assert idx.find(["xyzzyplugh"]) == []
|
||||
|
||||
|
||||
def test_format_for_prompt_includes_heading_and_text(fake_index: Path):
|
||||
idx = DocsIndex.load(fake_index)
|
||||
assert idx is not None
|
||||
chunks = idx.find(["route plan"], product="cucm")
|
||||
rendered = idx.format_chunks_for_prompt(chunks)
|
||||
assert "Route Plan Overview" in rendered
|
||||
assert "route plan defines" in rendered.lower()
|
||||
|
||||
|
||||
def test_format_empty_chunks(fake_index: Path):
|
||||
idx = DocsIndex.load(fake_index)
|
||||
assert idx is not None
|
||||
rendered = idx.format_chunks_for_prompt([])
|
||||
assert "No matching" in rendered
|
||||
70
tests/test_normalize.py
Normal file
70
tests/test_normalize.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""Tests for value normalization (Informix t/f → bool, etc.)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_cucm_axl.normalize import (
|
||||
BOOLEAN_COLUMNS,
|
||||
normalize_bool,
|
||||
normalize_row,
|
||||
normalize_rows,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeBool:
|
||||
def test_t_becomes_true(self):
|
||||
assert normalize_bool("t") is True
|
||||
|
||||
def test_f_becomes_false(self):
|
||||
assert normalize_bool("f") is False
|
||||
|
||||
def test_passthrough_other_strings(self):
|
||||
assert normalize_bool("True") == "True"
|
||||
assert normalize_bool("yes") == "yes"
|
||||
|
||||
def test_passthrough_native_types(self):
|
||||
assert normalize_bool(None) is None
|
||||
assert normalize_bool(0) == 0
|
||||
assert normalize_bool(True) is True
|
||||
assert normalize_bool([]) == []
|
||||
|
||||
|
||||
class TestNormalizeRow:
|
||||
def test_normalizes_known_boolean_columns(self):
|
||||
row = {"block_enabled": "t", "name": "RP-1", "ismessagewaitingon": "f"}
|
||||
result = normalize_row(row)
|
||||
assert result["block_enabled"] is True
|
||||
assert result["ismessagewaitingon"] is False
|
||||
assert result["name"] == "RP-1" # not a boolean column, untouched
|
||||
|
||||
def test_unknown_column_with_t_value_unchanged(self):
|
||||
# Conservative: only normalize columns we know are boolean.
|
||||
# Avoids false positives on columns where 't' is meaningful (e.g.
|
||||
# a single-character device class code).
|
||||
row = {"unknown_field": "t"}
|
||||
result = normalize_row(row)
|
||||
assert result["unknown_field"] == "t"
|
||||
|
||||
def test_empty_row(self):
|
||||
assert normalize_row({}) == {}
|
||||
|
||||
|
||||
class TestNormalizeRows:
|
||||
def test_processes_each_row(self):
|
||||
rows = [
|
||||
{"block_enabled": "t", "name": "RP-1"},
|
||||
{"block_enabled": "f", "name": "RP-2"},
|
||||
]
|
||||
result = normalize_rows(rows)
|
||||
assert result[0]["block_enabled"] is True
|
||||
assert result[1]["block_enabled"] is False
|
||||
|
||||
def test_empty_list(self):
|
||||
assert normalize_rows([]) == []
|
||||
|
||||
|
||||
class TestBooleanColumnsSet:
|
||||
def test_known_columns_present(self):
|
||||
# Smoke test that the canonical columns are in the set
|
||||
assert "block_enabled" in BOOLEAN_COLUMNS
|
||||
assert "blockenable" in BOOLEAN_COLUMNS
|
||||
assert "isemergencyservicenumber" in BOOLEAN_COLUMNS
|
||||
84
tests/test_sql_validator.py
Normal file
84
tests/test_sql_validator.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""Tests for the SELECT-only SQL guardrail."""
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_cucm_axl.sql_validator import validate_select, SqlValidationError
|
||||
|
||||
|
||||
class TestSelectAccepted:
|
||||
def test_simple_select(self):
|
||||
assert validate_select("SELECT * FROM device") == "SELECT * FROM device"
|
||||
|
||||
def test_with_cte(self):
|
||||
q = "WITH x AS (SELECT 1 FROM systables) SELECT * FROM x"
|
||||
assert validate_select(q) == q
|
||||
|
||||
def test_lowercase_select(self):
|
||||
assert validate_select("select * from numplan") == "select * from numplan"
|
||||
|
||||
def test_trailing_semicolon_stripped(self):
|
||||
assert validate_select("SELECT 1 FROM device;") == "SELECT 1 FROM device"
|
||||
|
||||
def test_block_comments_stripped(self):
|
||||
q = "/* comment */ SELECT 1 FROM device"
|
||||
cleaned = validate_select(q)
|
||||
assert "SELECT 1 FROM device" in cleaned
|
||||
|
||||
def test_line_comments_stripped(self):
|
||||
q = "-- a comment\nSELECT 1 FROM device"
|
||||
cleaned = validate_select(q)
|
||||
assert "SELECT 1 FROM device" in cleaned
|
||||
|
||||
|
||||
class TestRejected:
|
||||
def test_empty(self):
|
||||
with pytest.raises(SqlValidationError, match="empty"):
|
||||
validate_select("")
|
||||
|
||||
def test_whitespace_only(self):
|
||||
with pytest.raises(SqlValidationError, match="empty"):
|
||||
validate_select(" \n ")
|
||||
|
||||
def test_only_comments(self):
|
||||
with pytest.raises(SqlValidationError, match="empty"):
|
||||
validate_select("-- just a comment\n/* and another */")
|
||||
|
||||
def test_insert_rejected(self):
|
||||
with pytest.raises(SqlValidationError, match="INSERT"):
|
||||
validate_select("INSERT INTO device VALUES (1)")
|
||||
|
||||
def test_update_rejected(self):
|
||||
with pytest.raises(SqlValidationError, match="UPDATE"):
|
||||
validate_select("UPDATE device SET name='x' WHERE pkid='y'")
|
||||
|
||||
def test_delete_rejected(self):
|
||||
with pytest.raises(SqlValidationError, match="DELETE"):
|
||||
validate_select("DELETE FROM device WHERE pkid='y'")
|
||||
|
||||
def test_drop_rejected(self):
|
||||
with pytest.raises(SqlValidationError, match="DROP"):
|
||||
validate_select("DROP TABLE device")
|
||||
|
||||
def test_select_with_embedded_drop_rejected(self):
|
||||
# Belt-and-suspenders: even if "DROP" appears in a quoted string-ish
|
||||
# position our keyword filter still catches it. AXL would also reject
|
||||
# this, but failing fast on the client saves a SOAP round-trip.
|
||||
with pytest.raises(SqlValidationError, match="DROP"):
|
||||
validate_select("SELECT 1 FROM device; DROP TABLE device")
|
||||
|
||||
def test_truncate_rejected(self):
|
||||
with pytest.raises(SqlValidationError, match="TRUNCATE"):
|
||||
validate_select("TRUNCATE TABLE device")
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_keyword_as_column_name_blocked(self):
|
||||
# A column named "delete" would be blocked. This is acceptable —
|
||||
# the data dictionary doesn't use SQL keywords as column names,
|
||||
# and conservative blocking is the right call for v1.
|
||||
with pytest.raises(SqlValidationError):
|
||||
validate_select("SELECT delete FROM device")
|
||||
|
||||
def test_select_with_subquery(self):
|
||||
q = "SELECT name FROM device WHERE pkid IN (SELECT fkdevice FROM numplan)"
|
||||
assert "SELECT name FROM device" in validate_select(q)
|
||||
97
tests/test_wildcard.py
Normal file
97
tests/test_wildcard.py
Normal file
@ -0,0 +1,97 @@
|
||||
"""Tests for CUCM dial-plan wildcard pattern matching."""
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_cucm_axl.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):
|
||||
assert _wildcard_to_regex("X") == r"^\d$"
|
||||
|
||||
def test_bang_to_one_or_more_digits(self):
|
||||
assert _wildcard_to_regex("!") == r"^\d+$"
|
||||
|
||||
def test_anchored(self):
|
||||
regex = _wildcard_to_regex("9XXX")
|
||||
assert regex.startswith("^")
|
||||
assert regex.endswith("$")
|
||||
Loading…
x
Reference in New Issue
Block a user