Three findings from a margaret-hamilton-style review of the MCP server, fixed with regression tests written first (red → green). One bonus finding (huntpilotqueue column name) was surfaced by the third fix itself — exactly the audit-trust failure mode that fix exists to expose. CRITICAL #1 — sql_validator: comment-strip mutated string literals. The cleaned query returned by validate_select() is what travels to AXL. Previously, the comment-strip pass ran before the literal-aware pass, so `--` or `/* */` markers inside a string literal were silently eaten: input: WHERE description = 'Smith -- old line' to AXL: WHERE description = 'Smith (truncated mid-literal) The LLM saw rows that looked plausible but were not what its query asked for. "Confidently wrong" is exactly the failure mode the review was hunting. Fix: only strip comments on the analysis-only copy used for keyword detection. The cleaned output preserves the input verbatim (modulo trailing semicolon and outer whitespace). 6 new tests covering literal preservation across `--`, `/* */`, LIKE patterns with embedded comment markers, and forbidden keywords inside real comments. CRITICAL #2 — cache key omitted cluster identity. The on-disk cache key was `method::args_json`. An operator swapping AXL_URL between test and prod (or between two clusters) would silently serve stale data from cluster A as if from cluster B. The audit report would be confidently wrong with no signal anything happened. Fix: AxlCache now takes cluster_id and prefixes all keys with it. Server bootstrap derives cluster_id as a 12-char SHA-256 prefix of AXL_URL. cache_stats() surfaces both the current cluster_id and a `foreign_cluster_entries` count so an env-swap is visible. Schema migration handles pre-fix cache files via PRAGMA table_info introspection plus a one-shot ALTER TABLE ADD COLUMN. 5 new tests covering isolation, shared-id sharing, stats reporting, legacy DB upgrade, and per-cluster clear() scoping. MAJOR #3 — find_devices_using_css summary undercounted partial failures. The function is per-category resilient (one failed query doesn't kill the whole impact analysis), but the resilience never propagated up to the response. total_returned and any_truncated only reflected SUCCESSFUL categories. An LLM consuming "47 references" had no way to know 5 categories errored and the real number was likely much higher. Fix: response now includes complete: bool, categories_with_errors: int, and error_categories: [list]. The LLM/auditor sees the partial-failure state and can decide whether to act on incomplete data. 5 new tests using a FakeAxlClient stand-in to simulate per-category failures. BONUS finding (uncovered by Major #3 fix): huntpilotqueue join used the wrong column. Three CSS impact categories (huntpilot_max_wait_css, huntpilot_no_agent_css, huntpilot_queue_full_css) were silently erroring with "Column (fknumplan) not found" because huntpilotqueue joins via fknumplan_pilot, not fknumplan. With the Major #3 fix in place, this surfaced immediately as `complete: False, error_categories: [3 huntpilot_*]` against the live cluster. Fixed inline; live re-run now reports `complete: True, total_returned: 163` for Internal-CSS. 87 unit tests passing (up from 70). Live cluster smoke test (cucm-pub.binghammemorial.org, CUCM 15.0.1.12900-234) verifies all three fixes plus the bonus finding work end-to-end.
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_PTpartition, 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 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):
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):
# 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
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 pattern + CSS-member counts |
route_calling_search_spaces(name=None) |
CSS list with ordered partitions |
route_patterns(kind=None, partition=None, filter=None) |
Route Plan Report — patterns + transformations |
route_inspect_pattern(pattern, partition=None) |
Deep dive: transforms, route filter, reachable-from CSS, full destination chain (route list → groups → gateways) |
route_lists_and_groups(name=None) |
Route list → route group → gateway chain (annotates Local Route Group placeholders) |
route_translation_chain(number, css_name=None) |
Wildcard-aware pattern matcher: evaluates X / ! / [0-9] / @ / \+ against the number and returns matches sorted by specificity |
route_digit_discard_instructions() |
DDI catalog |
route_device_pool_route_groups(device_pool_name=None) |
How each device pool resolves Local Route Group placeholders to actual gateway-bearing groups |
route_devices_using_css(css_name) |
Impact analysis: every reference to a CSS across line CFA/CFB/CFNA/CFUR/translation/MWI/shared, device-level CSSs, voicemail pilots, route lists |
route_filters(name=None) |
Route filter clauses + member rules (composed with @-pattern routes) |
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 seedinvestigate_pattern(pattern, partition=None)— deep-dive a specific patternaudit_routing(focus="full")— comprehensive audit walkthroughcucm_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_chaindoes 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 byroute_patterns(kind=...)are stable across CUCM versions but enumerated against thetypepatternusagetable at query time, so any cluster-specific custom types still work.