"""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 _ConfigError(RuntimeError):
"""Permanent configuration error — pin and don't retry.
Used internally to distinguish "missing env var, bad WSDL path, etc."
(which won't get better until the operator fixes them) from operational
errors like network blips or session timeouts (which should retry).
"""
class AxlClient:
"""Lazy-loaded zeep client for CUCM AXL.
Hamilton review MAJOR #5: distinguishes configuration errors (pinned —
they don't get better on retry) from operational errors (transient —
next call should attempt fresh). Pre-fix, ANY first-time failure
pinned the client forever and required a server restart.
"""
def __init__(self, response_cache: AxlCache):
self._client: Client | None = None
self._service: Any = None
self._response_cache = response_cache
self._config_error: str | None = None # permanent, pinned
self._last_error: str | None = None # last seen, may be transient
self._connected_at: float | None = None # monotonic time of last success
self._retry_config: dict | None = None # populated when session is built
def connection_status(self) -> dict:
"""Diagnostic snapshot — what's the state of the connection?
Useful for the `health` MCP tool and for operators trying to
figure out why a tool call failed. Reports whether we're
currently connected, when we last successfully connected, the
last error (config or operational), and the rate-limit retry
policy in effect.
"""
return {
"connected": self._service is not None,
"connected_at_monotonic": self._connected_at,
"config_error": self._config_error, # permanent until restart
"last_error": self._last_error,
"retry_config": self._retry_config,
}
def _ensure_connected(self) -> None:
if self._service is not None:
return
# Configuration errors are permanent — don't waste time retrying.
if self._config_error is not None:
raise _ConfigError(self._config_error)
# Read env vars FIRST. Missing env is a config error (pinned).
try:
url = os.environ["AXL_URL"]
user = os.environ["AXL_USER"]
password = os.environ["AXL_PASS"]
except KeyError as e:
self._config_error = (
f"Missing required env var {e.args[0]}. "
f"Set AXL_URL, AXL_USER, AXL_PASS in .env or the environment."
)
self._last_error = self._config_error
raise _ConfigError(self._config_error) 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)
# Rate-limit / transient-error retry. CUCM's SOAP layer returns 503
# under load (multiple admins running AXL queries during a change
# window, etc). 502/504 occur when the publisher is restarting or
# a load balancer is between us and CUCM. Pre-fix, any of these
# was a hard failure to the caller; now they're retried with
# exponential backoff.
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
max_retries = int(os.environ.get("AXL_RATE_LIMIT_RETRIES", "3"))
if max_retries > 0:
retry = Retry(
total=max_retries,
backoff_factor=1.0, # 1s, 2s, 4s between retries
status_forcelist=(502, 503, 504),
allowed_methods=frozenset(["POST", "GET"]),
raise_on_status=False, # let zeep see the final response
respect_retry_after_header=True,
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
self._retry_config = {
"max_retries": max_retries,
"backoff_factor": 1.0,
"status_forcelist": [502, 503, 504],
}
# 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,
)
import time as _time
self._connected_at = _time.monotonic()
self._last_error = None # operational state is now clean
print(
f"[mcp-cucm-axl] connected to {url} (TLS verify={verify_tls})",
file=sys.stderr,
flush=True,
)
except Exception as e:
# Operational error (network, TLS, WSDL fetch failure). Don't
# pin — the next call should be allowed to retry. Just record
# the last error for diagnostics.
self._last_error = f"AXL connection failed: {e}"
print(
f"[mcp-cucm-axl] {self._last_error} (operational, will retry on next call)",
file=sys.stderr,
flush=True,
)
raise RuntimeError(self._last_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
`val...
`. 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 `` (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: [, , ...].
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)