Compare commits

..

10 Commits

Author SHA1 Message Date
ca6956e826 Rename to mcaxl + scrub for public PyPI release
Renames the package from `mcp-cucm-axl` to `mcaxl` to fit the
operator's mc<interface> naming convention (mcusb, mcaxl, …),
and scrubs Bingham-specific defaults so the package works for
anyone, anywhere.

Rename:
  - pyproject.toml: name, scripts entry point, description
  - src/mcp_cucm_axl/ → src/mcaxl/ (git mv preserves history)
  - All Python imports updated via sed
  - Cache directory: ~/.cache/mcp-cucm-axl/ → ~/.cache/mcaxl/
  - Log prefix [mcp-cucm-axl] → [mcaxl]
  - Package version lookup: importlib.metadata.version("mcaxl")
  - .mcp.json command updated to invoke `mcaxl` script
  - All 155 tests pass under the new name (verified)

Bingham-specific scrubs:
  - docs_loader._DEFAULT_INDEX_DIR: hardcoded /home/rpm/bingham/...
    path removed; defaults to None. Operators set CISCO_DOCS_INDEX_PATH
    env var; without it, prompts gracefully degrade with a fallback
    notice instructing the LLM to use the cisco-docs MCP search_docs
    tool instead.
  - prompts/_common.docs_or_empty_msg: removed the explicit
    /home/rpm/bingham/... path from the fallback message text.
  - server.py: removed dead-code copy of _docs_or_empty_msg() that
    was leftover from before the prompts package extraction.
  - README.md: completely rewritten as a public-facing readme. Lead
    paragraph names CUCM as the target platform, install instructions
    cover uvx / pip / Claude Code MCP add. Recommends cisco-cucm-mcp
    as the operations counterpart.

PyPI metadata:
  - Initial CalVer version: 2026.04.27
  - License: MIT (LICENSE file added)
  - Project URLs: Homepage / Source / Issues / Changelog all point
    at git.supported.systems/mcp/mcaxl (newly-created Gitea repo
    in the mcp/ org for PyPI releases)
  - Classifiers: Beta / Telecommunications Industry / Topic:Telephony
  - Keywords: mcp, cisco, cucm, axl, risport, voip, sip, audit
  - sdist excludes: CLAUDE.md, .env*, axlsqltoolkit.zip, audits/,
    tests/, pytest/ruff caches. Verified clean: wheel ships only the
    mcaxl/ source tree + LICENSE + METADATA + entry_points.

CHANGELOG.md added with a 2026.04.27 initial-release entry,
documenting tool/prompt counts, structural read-only guarantees,
Hamilton review closure, live-cluster verification, and known
limitations.

Build verification:
  - `uv build` produces clean wheel + sdist
  - Wheel: 22 source files, 195KB total, no Bingham-specific files
  - Sdist excludes verified: no CLAUDE.md, no axlsqltoolkit.zip
  - Entry point: `mcaxl = mcaxl.server:main`
  - Package installs as mcaxl==2026.4.27
2026-04-27 12:53:54 -06:00
39d4b29392 Add RisPort70 for real-time registration state + rate-limit backoff
Two ideas borrowed from cisco-cucm-mcp (calltelemetry/cisco-cucm-mcp,
MIT licensed): real-time device registration via RisPort70, and
exponential-backoff retry on transient HTTP 5xx errors. Both are
purpose-built for the audit use case rather than general-purpose
ports — RisPort tools exist to inform audit findings, not as a
standalone "look at my devices" interface.

Rate limit / 503 backoff (~30 lines + 3 tests):
  AxlClient now mounts an HTTPAdapter with a urllib3 Retry policy
  (3 retries, exponential backoff, status_forcelist=[502,503,504]).
  Configurable via AXL_RATE_LIMIT_RETRIES (default 3, 0 disables).
  Surfaces in connection_status() so operators can see the policy.
  Closes a real reliability gap: CUCM SOAP rate-limits under load
  during change windows or with multiple concurrent admins; pre-fix
  any 503 was a hard failure.

RisPort70 (new src/risport.py + 2 tools + prompt update):
  Hand-coded SOAP client for /realtimeservice2/services/RISService70
  (avoids dragging in another zeep instance for one operation).
  Reuses AXL_URL/USER/PASS env vars — RisPort lives on the same host.

  New tools:
    device_registration_status(device_class, status, name_filter, page_size)
    device_registration_summary()  — cluster-wide breakdown by class

  Live-cluster verification (cucm-pub.binghammemorial.org):
    Phone:    803  registered=679  unregistered=123  rejected=1
    Gateway:   85  registered=41   rejected=44   ← real audit finding
    SIPTrunk:  22  registered=18   unregistered=4
    HuntList:  28  registered=28
    H323/CTI:  0   (cluster doesn't use these)

  Discovered while live-verifying: CUCM 15 wraps the RisPort response
  in an extra <SelectCmDeviceResult> element inside <selectCmDeviceReturn>.
  Older CUCM versions exposed the fields directly. The parser falls
  back to either shape; tests cover both (test_legacy_response_shape_still_parses
  asserts the older shape still works).

phone_inventory_report prompt updated:
  New Step 3 — "Cross-reference with real-time registration" — recommends
  device_registration_summary() + device_registration_status(status="UnRegistered")
  to surface configured-but-never-registered phones (strongest orphan signal),
  PartiallyRegistered phones (firewall/cert/version mismatch indicator),
  and registration-state vs config-state mismatches.

Tooling delta worth noting:
  AXL device count:    1,377 phones
  RisPort device count:   803 phones
  Delta (~574)         likely templates, hidden phones, or stale config —
                       itself an audit finding the new tool will surface
                       to anyone running phone_inventory_report.

README updated:
  - Added health(), device_registration_status, device_registration_summary
  - Added "Scope and complement" section recommending @calltelemetry/cisco-cucm-mcp
    alongside for operational debugging (logs, perfmon, packet capture,
    service control). The two servers answer different questions; the LLM
    with both can compose audit findings with operational state.
  - Listed all 10 prompts (was 4 outdated entries).

Tests: 134 → 155 (+21).
2026-04-26 10:28:04 -06:00
9e5c195ce7 Fix issue #1: comprehensive CSS reference coverage (51 new categories)
Closes bingham/mcp-cucm-axl#1

route_devices_using_css missed device.fkcallingsearchspace_cgpntransform
and _cdpntransform — the columns trunks use to attach calling-party and
called-party number transformation CSSs. A CSS only referenced via these
columns showed up as "0 references" in impact analysis, leading an
operator to conclude safe-to-delete and break outbound transformations.

Same failure shape as Hamilton CRITICAL #2 (false-zero impact analysis)
but at a different schema layer: that fix added 7 reference points
covering the obvious cases; this fix closes the rest.

What's covered now (71 fkcallingsearchspace_* columns total across 14
tables in CUCM 15):

  Templates added for the bulk cases:
    _device_query(suffix)      — device.fkcallingsearchspace_<suffix>
    _devicepool_query(suffix)  — devicepool.fkcallingsearchspace_<suffix>
    _numplan_query(suffix)     — numplan.fkcallingsearchspace_<suffix>

  Categories added (51 new):
    11× device variants (incl. _cgpntransform and _cdpntransform — the issue)
    17× devicepool inheritance variants (closes M1 caveat from audit reports)
    13× numplan forwarding/transformation variants (cfbint/cfhr/etc.)
    site, externalcallcontrolprofile, recordingprofile, usageprofile,
    vipre164transformation×2, incomingtransformationprofile×4

Schema gotchas discovered and codified:
  - devicepool, externalcallcontrolprofile, recordingprofile have no
    `description` column (verified against syscolumns 2026-04-26)
  - site has neither `name` nor `description` — uses `tksite` enum joined
    against `typesite.name` for the human-readable form

Live verification on cucm-pub.binghammemorial.org (CUCM 15.0.1.12900-234):
  XFORM-Outbound-ANI:  0 → 1 ref  (PSTN-Router-SIP-Trk via _cgpntransform)
  XFORM-Outbound-DNIS: 0 → 1 ref  (PSTN-Router-SIP-Trk via _cdpntransform)
  E911CSS:             unchanged at 0, but now with `complete: True`
                       — upgrades from "appears orphan with caveat" to
                       "confirmed orphan" since DP variants now covered
  Internal-CSS:        163 → 174 refs (DP + extra numplan variants)

Tests (128 → 134, +6):
  test_issue_1_cgpntransform_column_enumerated
  test_issue_1_cdpntransform_column_enumerated
  test_finds_trunk_via_cgpntransform_reference (mock-driven E2E)
  test_complete_schema_coverage_against_known_columns
    — encodes the 71-column snapshot from CUCM 15. If a future CUCM
      version adds a new fkcallingsearchspace_* column, the test fires
      red so the contributor knows to add it to _CSS_REFERENCE_QUERIES.
  test_no_duplicate_table_column_pairs
    — guards against double-counting if two categories accidentally
      reference the same column.
  test_error_in_multiple_tables_propagates
    — verifies error reporting works across the new shared-suffix cases
      (e.g., _cgpnunknown on both device AND devicepool).
2026-04-26 08:54:58 -06:00
8815db06d8 Add whoami prompt — single-user role chain with AXL service-account default
Operator-suggested prompt: "what does my AXL account *actually* have
permission to do?" Resolves the user → access-control-group →
function-role chain for a single account, defaulting to the AXL service
account from AXL_USER env when no userid is given.

The prompt principle came in using table names from older Cisco
docs (`enduserauthgroupmap`, `dirgrouprolemap`) that don't exist on
CUCM 15. The shipped SQL uses the verified CUCM 15 names
(`enduserdirgroupmap`, `functionroledirgroupmap`); a regression test
asserts the deprecated names don't appear in the rendered SQL section,
so any future "fix" reverting to the older names fires red.

Live verification on cucm-pub.binghammemorial.org found the existing
AXL service account (`SupportedSystemsReadOnly`) has 4 roles via the
`ReadOnly-AXL` access control group:
  - Standard AXL API Access  (full RW — group misnamed)
  - Standard AXL Read Only API Access  (the genuinely-read-only one)
  - Standard Packet Sniffing  (PHI-relevant in healthcare)
  - Standard RealtimeAndTraceCollection

The first finding is structural: the group `ReadOnly-AXL` contains
the FULL RW role `Standard AXL API Access` despite its name. The
MCP server's structural read-only enforcement (no write methods
registered) is what prevents this from mattering — but the account
itself is over-privileged relative to what the tool needs. The
prompt's findings template surfaces this kind of misnamed-group
case explicitly.

Also discovered (and documented in the prompt body): AXL auth is
case-insensitive for usernames, but SQL `WHERE name = 'X'` is
case-sensitive. Step 3 of the prompt handles the case-mismatch
fallback so a typo like `SupportedSYstemsReadOnly` (env) vs
`SupportedSystemsReadOnly` (cluster canonical) doesn't produce a
silently-empty result.

5 new tests:
  - correct CUCM 15 table names embedded in SQL
  - explicit userid threads through to the query
  - default reads AXL_USER from env
  - missing userid AND missing env → clear instruction
  - SQL injection defense (single-quote escape)

123 → 128 tests; 9 → 10 prompts. Prompt registration smoke test
updated to assert the new shim is wired.
2026-04-26 00:05:31 -06:00
8aaeb04417 Add 4 audit prompts: phone_inventory, user_audit, inbound_did_audit, hunt_pilot_audit
Builds on the prompts-package extraction. Each new prompt embeds
schema-verified SQL plus a findings template tuned to surface
audit-actionable issues (orphans, drift, capacity outliers, security
posture).

phone_inventory_report(filter=None):
  Aggregates by model / device pool / CSS, then anomaly queries for
  phones with no description, phones whose description echoes their
  MAC-based name, phones with no owner, phones in non-default CSS.
  Cross-references owner status (phones owned by inactive users
  surface as findings).

user_audit(focus=full|admin|inactive|app_users):
  End user + application user inventory, role/group assignments via
  the enduserdirgroupmap → dirgroup → functionroledirgroupmap →
  functionrole join chain. Security-critical findings: app users
  with admin-grade role memberships, local-user accounts with admin
  privileges, phones owned by inactive users.

inbound_did_audit():
  Reusable form of today's cucm-inbound-did-inventory work. XFORM-
  Inbound-DNIS curated list categorized (pass-through, block-trans,
  specific renames, wildcards, catch-all hazard). Cross-checked
  against Internal-PT route patterns and the operator-curated
  PSTN-Screen-PT spam blocklist. Findings for orphan target
  extensions and the silent !-catch-all risk.

hunt_pilot_audit():
  Hunt pilot inventory with queue settings, line group membership,
  and distribution algorithm decoding. Schema knowledge already
  Hamilton-verified: huntpilotqueue joins via fknumplan_pilot, NOT
  fknumplan (the test asserts the correct column appears in the
  rendered prompt). Findings: queue misconfigurations (NULL
  destinations, infinite max-wait), empty line groups, dead pilots
  with no route-list destination.

Implementation notes:
  - Each prompt's SQL was validated against the live cluster
    (cucm-pub.binghammemorial.org, CUCM 15.0.1.12900-234).
  - user_audit originally used UNION ALL with NULL-typed status
    column for the headcounts query; Informix rejected it. Split
    into two simpler queries (commented in the prompt body).
  - phone_inventory_report uses a Hamilton-style SQL escape for
    the optional name_filter (single quotes doubled).
  - All four prompts gracefully degrade when the docs index isn't
    loaded (verified by test_all_new_prompts_render_without_docs).

114 → 123 tests; 5 → 9 prompts. Full live-cluster verification:
  - 12 phone models, 629 Cisco 7841 phones (largest model)
  - 1,246 active end users, 25 application users
  - Hunt pilots with named distribution algorithms (Broadcast, Top
    Down, etc.) — confirms typedistributealgorithm join works
  - Hamilton-fixed huntpilotqueue.fknumplan_pilot column verified
    in the embedded SQL via dedicated regression test.
2026-04-25 23:57:01 -06:00
e6aa075793 Extract prompts into a package + add sip_trunk_report
Refactor: the four existing inline prompts in server.py move into
individual modules under src/mcp_cucm_axl/prompts/. Server.py keeps
thin @mcp.prompt-decorated shims that delegate to the corresponding
render() function — FastMCP needs the shims because it introspects
their signatures to expose parameters to the LLM, but the prompt
*content* now lives one-prompt-per-file.

Why: server.py's prompt section had grown to ~200 lines of inline
markdown. As more query patterns get documented (see
docs/query-patterns/) this would only worsen. Per-module bodies are
easier to diff, review, and unit-test in isolation.

Layout:
  src/mcp_cucm_axl/prompts/
    __init__.py
    _common.py             — shared helpers, keyword sets, render_schema_block
    route_plan_overview.py
    investigate_pattern.py
    audit_routing.py
    cucm_sql_help.py
    sip_trunk_report.py    — NEW

Each prompt module exports a `render(docs, *args) -> str` function
that takes the DocsIndex as a parameter (no module globals). The
shim in server.py grabs the runtime `_docs` and passes it in. Pure
functions = trivially unit-testable.

NEW prompt: sip_trunk_report.

Implementation reference: docs/query-patterns/sip-trunk-report.md
(written separately as a query-pattern doc, validated against the
live cluster). The prompt embeds:
  - Step 1: trunk inventory SQL (device + sipdevice + 5 LEFT JOINs)
  - Step 2: per-destination SQL (siptrunkdestination)
  - Step 3: pointer to existing route_lists_and_groups() tool
  - Step 4: findings template (SPOF, profile sprawl, CSS asymmetry,
    codec heterogeneity, DNS-vs-IP, security posture)

Optional `name_filter` parameter narrows the inventory via LIKE; the
filter value is escaped for SQL safety (single quotes doubled per
Informix convention).

Tests: 14 new in tests/test_prompts_package.py covering each
prompt's render() with and without docs, plus a registration smoke
test that confirms the FastMCP shim set matches the prompts package
exports (catches the case where a new module is added without its
shim).

Total: 100 → 114 tests; 5 prompts registered; live verification
against cucm-pub.binghammemorial.org confirms the embedded SQL
produces real inventory data. The four original prompts are
behaviorally identical to before — same content, just relocated.
2026-04-25 23:29:05 -06:00
2690c2225b docs: query-pattern for SIP trunk inventory report
Document the SQL queries used to build a comprehensive SIP trunk
inventory (device + sipdevice + siptrunkdestination joins, plus
route_lists_and_groups for membership). Captures rationale for each
column, common gotchas (routelistdetail doesn't exist, lvarchar(1)
flag fields return 't'/'f' strings), and a draft prompt signature
suggesting how to extract this into a @mcp.prompt function in
server.py — same shape as the existing route_plan_overview /
investigate_pattern / audit_routing prompts.

Empty src/mcp_cucm_axl/prompts/ directory remains unused; this lives
under docs/ since it's reference material rather than a runtime
prompt. Future commit can promote the queries into the prompt
function and delete this if redundant.

Live result snapshot included for reference (CUCM 15.0.1.12900-234,
2026-04-25, 11 trunks).
2026-04-25 23:25:49 -06:00
90227ab391 Hamilton review fixes part 2: bounded regex, connection recovery, _to_int diagnostic, consistent error shapes
Closes the four remaining findings from the margaret-hamilton review.
13 new regression tests; all 100 pass; live cluster smoke verified.

MAJOR #4 — wildcard regex catastrophic backtracking + silent malformed.

Two changes to _wildcard_to_regex():

a) Bounded the `!` and `@` wildcards to \d{1,50} (was \d+). Adjacent
   `!` patterns previously compiled to (\d+)(\d+)... which has
   exponential backtracking on near-miss inputs. CUCM dial strings
   are practically capped well below 50 digits; the bound keeps
   complexity polynomial without losing real-world coverage.
   Verified: 10 adjacent `!` against a 30-digit near-miss now finishes
   in ~240ms (was unbounded; could have been minutes on real
   pathological cases).

b) Unclosed `[` now raises ValueError instead of silently treating the
   bracket as a literal. _pattern_matches_number catches the error
   and returns False so a single bad pattern doesn't crash
   translation_chain — but the bad pattern is no longer invisibly
   producing wrong matches. The previous silent fallback meant a
   pattern like `[0-9` (typo, missing `]`) would match input
   containing the literal characters `[` `0` `-` `9`.

3 new tests covering: bounded-regex shape (`\d{1,N}`), pathological
input completes quickly, unclosed bracket raises explicitly,
well-formed character class still works.

MAJOR #5 — distinguish config errors from operational errors.

Pre-fix: any first-time connection failure set `_connection_error`
and pinned it forever. A transient network blip or session timeout
required restarting the MCP server. Hamilton's framing: Apollo's
software was *designed* to recover from transient faults; pinning
forever is the antithesis of "design the error path first."

Fix: split into two state fields:
  _config_error  — permanent until restart (missing env vars only)
  _last_error    — last operational failure, NOT a pin

Operational failures (zeep Client construction, network, TLS, session)
clear from the next call's perspective: the next call attempts fresh.
Configuration errors (missing AXL_URL etc.) stay pinned because
they don't get better on retry.

Added _ConfigError as a private subclass to make the distinction
explicit at the raise site, and connection_status() to expose
connected/connected_at/config_error/last_error for diagnostic
transparency.

3 new tests: config errors pin, operational errors don't pin,
connection_status() reports state.

MINOR #6 — _to_int silent coercion of bad data.

Pre-fix: a non-numeric value from the cluster (data corruption,
schema drift across CUCM versions) silently became None, which
downstream sort logic defaulted to 0 — jumbling the failover order
in the displayed result with no warning.

Fix: still returns None on bad data (caller error path unchanged),
but logs the offending value to stderr so an operator notices
something's wrong at the data layer. None itself is silent
(legitimately-unset column).

2 new tests: real None is silent, bad string logs to stderr with
the offending value visible.

MINOR #7 — standardize tool failure shapes; add health() tool.

Pre-fix: cache_stats and cache_clear returned `{"error": "..."}`
when _cache was None, while AXL-touching tools raised RuntimeError.
LLM consumers had to handle two shapes.

Fix: _require_cache() helper raises RuntimeError consistently with
_client(). All tool failures now use the same exception shape.
Added health() tool that reports cache/axl/docs initialization
status plus the AXL connection_status — gives operators a
self-diagnostic when something fails at bootstrap.

3 new tests: cache_stats raises, cache_clear raises, health()
reports each subsystem.
2026-04-25 23:19:32 -06:00
dee5fdacda Hamilton review fixes: validator literal preservation, cache cluster id, CSS impact partial-failure reporting
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.
2026-04-25 23:09:55 -06:00
82d8fbe563 SQL validator: ignore string literals; CSS impact: add primary + 7 more
Two defects found during live-cluster audit shakedown.

1. SQL validator false-positives on string literals
   The forbidden-keyword check tokenized the entire query, including
   contents of single-quoted string literals. CSS names like
   'Call Forward-CSS', DN descriptions containing 'DELETE', or partition
   names with 'INSERT' all tripped the validator even though the SQL
   itself was clean read-only. Found while running impact analysis on
   "Call Forward-CSS".

   Fix: strip string literals (single-quoted, with '' as escape) into
   whitespace before the forbidden-keyword tokenization. The cleaned
   query returned to the caller still contains the literals — they're
   only invisible to the analysis pass.

   7 new tests covering: words inside literals (Call/Drop/Delete/etc.),
   escaped quotes, multiple literals, and the critical case where a
   forbidden keyword appears immediately after a literal.

2. CSS impact analysis missed primary device CSS + 7 other refs
   Running route_devices_using_css("E911CSS") returned total=0 even
   though E911CSS is configured in the cluster. Root cause: our
   enumeration covered device.fkcallingsearchspace_{reroute,restrict,
   refer,rdntransform} but not the primary device.fkcallingsearchspace
   itself — the column the GUI sets when assigning a CSS to a phone.
   The simple unsuffixed name didn't match our earlier "%css%" schema
   filter (the actual column spells out "callingsearchspace").

   Added 8 new reference categories:
     device_primary_css                — the big one
     device_cgpn_unknown_css           — calling-party-unknown
     line_monitoring_css               — devicenumplanmap monitoring CSS
     gateway_h323_called_xform_css     — H.323 gateway transform
     gateway_sip_called_xform_css      — SIP trunk transform
     huntpilot_max_wait_css            — hunt pilot queue handling
     huntpilot_no_agent_css            — hunt pilot queue handling
     huntpilot_queue_full_css          — hunt pilot queue handling

   Re-running on live cluster:
     Internal-CSS:     146 -> 163 refs (16 new device_primary_css matches)
     Call Forward-CSS: previously rejected by validator -> 150 refs
     E911CSS:          still 0 — high-confidence orphan finding now
2026-04-25 20:50:57 -06:00
42 changed files with 4760 additions and 514 deletions

View File

@ -6,7 +6,7 @@
"run",
"--directory",
"/home/rpm/bingham/axl",
"mcp-cucm-axl"
"mcaxl"
]
}
}

67
CHANGELOG.md Normal file
View File

@ -0,0 +1,67 @@
# Changelog
This project uses [CalVer](https://calver.org/) — version numbers
encode the date the package was tested against the upstream Cisco APIs
and published. Format: `YYYY.MM.DD` with optional `.N` post-release
suffix for same-day fixes.
## 2026.04.27 — initial public release
First public release on PyPI as `mcaxl`. Renamed from the internal
working name `mcp-cucm-axl` to fit the operator's `mc<interface>`
naming convention.
### Tools (19 total)
**Foundational**: `axl_version`, `axl_sql`, `axl_list_tables`,
`axl_describe_table`, `cache_stats`, `cache_clear`, `health`.
**Route plan**: `route_partitions`, `route_calling_search_spaces`,
`route_patterns`, `route_inspect_pattern`, `route_lists_and_groups`,
`route_translation_chain`, `route_digit_discard_instructions`,
`route_device_pool_route_groups`, `route_devices_using_css`,
`route_filters`.
**Real-time registration (RisPort70)**: `device_registration_status`,
`device_registration_summary`.
### Prompts (10 total)
Schema-grounded conversation seeds: `route_plan_overview`,
`investigate_pattern`, `audit_routing`, `cucm_sql_help`,
`sip_trunk_report`, `phone_inventory_report`, `user_audit`,
`inbound_did_audit`, `hunt_pilot_audit`, `whoami`.
### Engineering rigor
- **Read-only by structural guarantee**: no AXL write methods are
registered; the SQL validator rejects non-SELECT/WITH queries as
defense-in-depth.
- **Hamilton-style review closed**: 7 findings (2 Critical, 3 Major,
2 Minor) addressed during pre-release hardening, each with a
regression test.
- **Live-cluster verified**: every tool path verified against a
production CUCM 15.0.1.12900 cluster before release.
- **155 unit tests**, schema drift guard for all 71 known
`fkcallingsearchspace_*` columns (via
`test_complete_schema_coverage_against_known_columns`).
### Known limitations
- `route_translation_chain` evaluates CUCM wildcards (`X`, `!`,
`[0-9]`, `@`, `\\+`) but does not model route-filter constraints on
`@` patterns — use as guidance, not authoritative.
- AXL WSDL must be supplied externally (Cisco-licensed; not bundled).
See `README.md` for bootstrap instructions.
- RisPort `state_info` cursor pagination is implemented but not yet
stress-tested on clusters with > 1000 devices in a single class.
### Acknowledgments
Borrowed two ideas from
[`@calltelemetry/cisco-cucm-mcp`](https://github.com/calltelemetry/cisco-cucm-mcp)
(MIT licensed): the RisPort70 SOAP envelope shape and the
exponential-backoff retry policy on HTTP 503. Their tool covers
operational debugging (logs, perfmon, packet capture) — install both
side-by-side for compound questions like *"audit found CSS X
unreferenced AND RisPort confirms zero phones registered against it."*

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ryan Malloy <ryan@supported.systems>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

213
README.md
View File

@ -1,86 +1,99 @@
# mcp-cucm-axl
# mcaxl
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.
Read-only MCP server for **Cisco Unified Communications Manager (CUCM)**
exposes the AXL SOAP API and RisPort70 real-time registration state to
LLMs for dial-plan analysis, configuration auditing, and impact analysis.
> Tested against CUCM 15.0.1.12900. Should work on any CUCM 12.5+.
## Why this exists
CUCM's admin UI is great for one-config-at-a-time work but painful for
audit/discovery questions like:
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?"
- *"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 our PSTN carrier."*
- *"Are there partitions defined but unreachable from any CSS?"*
- *"Which phones are configured but not currently registered?"*
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.
`mcaxl` gives an LLM SQL access to CUCM's Informix data dictionary,
schema-aware joins for common audit questions, and RisPort70
cross-reference for live registration state. Then a set of curated
prompts orchestrates the tools toward audit *findings*, not just data.
## 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`.
`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
For operations that require write access (service control, packet capture,
log download, perfmon, etc.), install
[`@calltelemetry/cisco-cucm-mcp`](https://github.com/calltelemetry/cisco-cucm-mcp)
alongside this server. The two are complementary — `mcaxl` answers
"what does the config say?", `cisco-cucm-mcp` answers "what's happening
right now?".
### 1. Configure environment
## Install
Edit `.env` (already gitignored):
```bash
# Run directly from PyPI:
uvx mcaxl
```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
# Or as a pinned dev install:
pip install mcaxl
# Or via Claude Code's MCP registry:
claude mcp add cucm-axl -- uvx mcaxl
```
### 2. Bootstrap the AXL WSDL
## Configure
Download the **Cisco AXL Toolkit** from your CUCM admin UI:
Set these env vars (most operators use a `.env` file in the working directory):
```env
AXL_URL=https://your-cucm-pub:8443/axl/
AXL_USER=your-axl-service-account
AXL_PASS=your-password
# Optional:
AXL_VERIFY_TLS=false # CUCM ships self-signed certs; default off
AXL_CACHE_TTL=3600 # response cache TTL in seconds; 0 disables
AXL_RATE_LIMIT_RETRIES=3 # 502/503/504 retry count with backoff
AXL_WSDL_PATH= # explicit WSDL location override
AXL_WSDL_ZIP= # explicit toolkit zip path
CISCO_DOCS_INDEX_PATH= # for prompt enrichment (see Prompts section)
```
The AXL service account needs the **`Standard AXL Read Only API Access`**
role at minimum. It does *not* need the full `Standard AXL API Access`
role (read-write) — `mcaxl` is structurally incapable of using write
permissions even if granted.
## AXL WSDL bootstrap
CUCM's AXL toolkit is Cisco-licensed and not redistributable, so it's
not bundled. Download 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):
Drop the resulting `axlsqltoolkit.zip` into your working directory. On
first launch, the server auto-extracts `schema/15.0/` (or whichever
version matches your cluster) into `~/.cache/mcaxl/wsdl/15.0/`.
Alternative resolution paths (in 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/
export AXL_WSDL_ZIP=/path/to/axlsqltoolkit.zip # explicit zip
export AXL_WSDL_PATH=/path/to/schema/15.0/AXLAPI.wsdl # explicit WSDL
# Or pre-populate the cache:
mkdir -p ~/.cache/mcaxl/wsdl/15.0/
cp /path/to/schema/15.0/* ~/.cache/mcaxl/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
## Tool surface (19 total)
### Foundational
@ -91,6 +104,7 @@ opens this directory.
| `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 |
| `health()` | Subsystem self-check (cache / AXL / docs / RisPort init state) |
### Route plan
@ -100,34 +114,75 @@ opens this directory.
| `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_lists_and_groups(name=None)` | Route list → route group → gateway chain |
| `route_translation_chain(number, css_name=None)` | Wildcard-aware pattern matcher |
| `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) |
| `route_device_pool_route_groups(device_pool_name=None)` | Local Route Group resolution |
| `route_devices_using_css(css_name)` | Impact analysis across 71 known fk-CSS columns |
| `route_filters(name=None, include_members=False)` | Route filter clauses + member rules |
## Prompts
### Real-time device registration (RisPort70)
Schema-grounded conversation seeds. They pull relevant chunks from the
sibling `cisco-docs` index and embed them inline:
| Tool | Purpose |
|---|---|
| `device_registration_status(device_class, status, name_filter, page_size)` | Page through CUCM's RisPort `selectCmDevice` for live registration state |
| `device_registration_summary()` | Cluster-wide breakdown across Phone, Gateway, SIPTrunk, HuntList, etc. |
## Prompts (10 total)
Each prompt orchestrates multiple tool calls toward a specific
audit narrative. They appear in Claude Code's slash menu under
`/mcp__cucm-axl__<name>`:
- `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
- `investigate_pattern(pattern, partition=None)` — single-pattern deep dive
- `audit_routing(focus="full")` — comprehensive walkthrough with checklist
- `cucm_sql_help(question)` — catch-all SQL helper
- `sip_trunk_report(name_filter=None)` — SIP trunk inventory + findings
- `phone_inventory_report(filter=None)` — phone fleet aggregates with anomaly findings (cross-references RisPort)
- `user_audit(focus="full")` — end users + app users + role assignments
- `inbound_did_audit()` — XFORM-Inbound-DNIS inventory + screening pipeline
- `hunt_pilot_audit()` — hunt pilots, queue settings, line group membership
- `whoami(userid=None)` — single-user role chain (defaults to AXL service account)
### Optional: schema-grounded prompt enrichment
Set `CISCO_DOCS_INDEX_PATH` to a directory containing `chunks.jsonl`
and `index_meta.json` (produced by the
[`mcp-cisco-docs`](https://github.com/...) indexer or any compatible
embedding pipeline) to have prompts pull relevant Cisco documentation
chunks inline. Without this, prompts gracefully degrade to a fallback
notice instructing the LLM to use the sibling cisco-docs server's
`search_docs` tool.
## 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.
Responses are cached in SQLite at
`~/.cache/mcaxl/responses/axl_responses.sqlite`. The cache is
**cluster-isolated** by SHA-256 of `AXL_URL` — pointing the server
at a different cluster never serves stale data from a previous one.
Cache survives restarts. Clear with `cache_clear()` after a known
config change.
## Notes
## Caveats
- `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.
- `route_translation_chain` evaluates CUCM wildcards (`X`, `!`, `[0-9]`,
`@`, `\+`) but does *not* model route-filter constraints on `@`
patterns. Use as guidance, not authoritative.
- The package's `recordingprofile` / `usageprofile` / `vipre164transformation`
reference categories were schema-verified against CUCM 15. If a future
CUCM version adds new `fkcallingsearchspace_*` columns,
`route_devices_using_css`'s coverage will lag until the package is
updated. The
`test_complete_schema_coverage_against_known_columns` test enforces
the current snapshot — failing red surfaces the drift loudly.
## License
MIT. See `LICENSE`.
## Source
- Repo: [git.supported.systems/mcp/mcaxl](https://git.supported.systems/mcp/mcaxl)
- Issues: [git.supported.systems/mcp/mcaxl/issues](https://git.supported.systems/mcp/mcaxl/issues)
- Changelog: [`CHANGELOG.md`](./CHANGELOG.md)

View File

@ -0,0 +1,278 @@
# SIP Trunk Report — Query Pattern
**Goal:** Produce a comprehensive inventory of every SIP trunk on a CUCM
cluster, with destinations, profile assignments, and downstream
route-group/route-list membership. Useful for handoff documentation,
post-migration cleanup, and identifying single-points-of-failure on
specific trunks.
**Status:** Validated against CUCM 15.0.1.12900-234 on 2026-04-25.
Empty `prompts/` directory at `src/mcaxl/prompts/` is the
intended home for extracting this into a `@mcp.prompt` function. For
now, prompts live inline in `server.py` (see `route_plan_overview`,
`investigate_pattern`, `audit_routing`).
---
## Source-of-truth tables
| Table | Holds |
|---|---|
| `device` | Trunk row: name, description, FKs to profiles/CSS/pool/location |
| `sipdevice` | SIP-specific config: codec, calling-party selection, RDNIS handling, security, UR I domain |
| `siptrunkdestination` | One row per destination IP/port (a trunk can have multiple, ordered by `sortorder`) |
| `typeclass` | Device class enum — filter `tc.name = 'Trunk'` |
| `sipprofile` | SIP Profile name (joined via `device.fksipprofile`) |
| `callingsearchspace` | CSS name (joined via `device.fkcallingsearchspace`) |
| `devicepool` | Device Pool name (joined via `device.fkdevicepool`) |
| `location` | Location name for CAC/RSVP (joined via `device.fklocation`) |
| `typesipcodec` | Codec name enum (joined via `sipdevice.tksipcodec`) |
**Not directly relevant but worth knowing:**
- `sipsecurityprofile` — name lookup for `device.fksecurityprofile`. Skipped in the
query below because the security profile name is rarely informative on a
routine trunk inventory; add the join if security posture matters for the
use case.
- `siptrunkoauth` — additional auth config for OAuth-authenticated trunks.
---
## Query 1 — Trunk inventory (one row per trunk)
Joins `device` + `sipdevice` and pulls the human-readable names of every FK
field that operators typically want when scanning trunks.
```sql
SELECT
d.name AS trunk_name,
d.description,
sp.name AS sip_profile,
css.name AS calling_search_space,
dp.name AS device_pool,
loc.name AS location,
tsc.name AS preferred_codec,
sd.requesturidomainname AS sip_domain,
sd.isanonymous AS anon_caller_id,
sd.preferrouteheaderdestination AS prefer_route_header,
sd.acceptinboundrdnis AS accept_inbound_rdnis,
sd.acceptoutboundrdnis AS accept_outbound_rdnis
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
JOIN sipdevice sd ON sd.fkdevice = d.pkid
LEFT JOIN sipprofile sp ON d.fksipprofile = sp.pkid
LEFT JOIN callingsearchspace css ON d.fkcallingsearchspace = css.pkid
LEFT JOIN devicepool dp ON d.fkdevicepool = dp.pkid
LEFT JOIN location loc ON d.fklocation = loc.pkid
LEFT JOIN typesipcodec tsc ON sd.tksipcodec = tsc.enum
WHERE tc.name = 'Trunk'
ORDER BY d.name;
```
**Why these specific columns:**
- `description` — operator's free-form annotation; almost always names the
upstream device + IP, useful when the trunk name itself is opaque.
- `sip_profile` — drives transport (UDP/TCP/TLS), early offer, OPTIONS ping,
100rel, etc. Trunks sharing a SIP profile share *all* of those settings.
- `calling_search_space` — the CSS used when this trunk *originates* a call
(typical for inbound from a SIP carrier hitting the CUCM trunk).
- `device_pool` + `location` — clustering and CAC/RSVP grouping. In a
single-site cluster these are usually homogeneous.
- `preferred_codec` — the codec CUCM advertises first in SDP from this trunk.
- `accept_inbound_rdnis` / `accept_outbound_rdnis` — does the trunk pass RDNIS
(Redirected Dialed Number Identification Service) on diversions/forwards?
Voicemail trunks need both `t`; PSTN-facing trunks usually `f`.
**LVARCHAR(1) flag fields** (`anon_caller_id`, `prefer_route_header`,
`accept_inbound_rdnis`, `accept_outbound_rdnis`) return `'t'` or `'f'` — not
booleans. Render appropriately in any output.
---
## Query 2 — Destinations (one row per destination IP/port)
A trunk can have multiple destinations (active/active or
active/standby — sortorder controls retry order). Separate query because of
the one-to-many relationship.
```sql
SELECT
d.name AS trunk_name,
std.address,
std.port,
std.sortorder
FROM siptrunkdestination std
JOIN sipdevice sd ON std.fksipdevice = sd.pkid
JOIN device d ON sd.fkdevice = d.pkid
ORDER BY d.name, std.sortorder;
```
**Notes:**
- `address` is `VARCHAR(255)` — IP literal *or* DNS name. Expressway-C
trunks often use FQDNs (e.g., `exp-c-p.binghammemorial.org`) so SRV
resolution can shift the actual destination.
- `addressipv6` exists on the same table but is empty on most clusters.
- `port` is `INTEGER` — defaults to 5060 (SIP over UDP/TCP) or 5061 (TLS),
but custom ports are common for non-standard integrations (RightFax,
recording platforms).
---
## Query 3 — Route-group / route-list membership
**Don't write raw SQL for this** — the relevant join table is
`devicenumplanmap`-adjacent and its name has shifted across CUCM versions.
Use the existing MCP tool:
```
route_lists_and_groups()
```
Filter the result for `route_groups[].devices[].class == "Trunk"` to get
the set of `(trunk → route group → route list)` triples. Note that some
route lists have route groups with **no static device members**
those resolve to a Local Route Group via the calling phone's device-pool
`fkroutegroup_local` mapping at call-time (the CUCM Standard Local Route
Group feature). Trunks reachable only through Local Route Groups
won't appear in the static result and require a follow-up call to
`route_device_pool_route_groups()` to enumerate.
---
## Common gotchas
1. **`routelistdetail` doesn't exist.** I tried it; it fails. The actual
table name varies, and the join logic for route-list → route-group →
device is non-obvious. Use the MCP tool above.
2. **`securityprofile` is `sipsecurityprofile`** for SIP trunks (not the
generic `phonesecurityprofile`). If you add the security profile join,
use the SIP-specific table.
3. **`tkclass` filters by class enum, not text** — but `typeclass.name`
provides the human-readable label. The query above filters on
`tc.name = 'Trunk'` which matches all SIP and ICT trunks. To narrow
to SIP-only, also require `EXISTS (SELECT 1 FROM sipdevice sd WHERE
sd.fkdevice = d.pkid)` (or the inner `JOIN sipdevice` already does that).
4. **Trunks without a primary CSS** are valid — Expressway-C trunks on
this cluster have `fkcallingsearchspace = NULL`. Use `LEFT JOIN` and
render NULL as "(none)" rather than treating it as a finding.
---
## Suggested follow-up tool calls
After running Query 1+2 and `route_lists_and_groups()`, the audit
narrative usually wants:
1. `route_devices_using_css(css_name=<each unique trunk CSS>)` — see what
else uses the same CSS as a particular trunk; helps identify shared
blast-radius dependencies.
2. `route_inspect_pattern(pattern, partition)` — for each route pattern
that targets a trunk-bearing route list, walk the call path.
3. `axl_sql("SELECT name, description FROM sipprofile WHERE pkid IN (...)")`
if multiple trunks share a SIP profile, look up the profile's full
detail (transport, early-offer, ping, etc.) once.
---
## Findings template (what to call out)
When this query is wrapped in a `@mcp.prompt`, the prompt should ask the
LLM to surface:
- **Single-point-of-failure trunks**: any route group with one trunk
member where that route group is the only path for a critical pattern
(911, voicemail, fax). Cross-reference with
`route_lists_and_groups()` device counts.
- **Profile sprawl vs. consolidation**: are 11 trunks using 11 different
SIP profiles, or do most share a small number? Sprawl = harder to
audit transport/timing settings consistently.
- **CSS asymmetry**: are PSTN-facing inbound trunks using a restrictive
CSS that prevents them from reaching internal extensions? Are
internal-facing trunks (voicemail) using a permissive CSS? Mismatches
can cause one-way audio or routing failures.
- **Codec heterogeneity**: most clusters standardize on G.711 µ-law.
Trunks advertising G.722 or G.729 first warrant explanation.
- **DNS-vs-IP destinations**: trunks using FQDNs depend on cluster DNS;
flag if the FQDN resolution path adds a SPOF the audit hadn't
surfaced (e.g., single DNS server).
- **Security posture**: trunks using `Non Secure SIP Trunk Profile` for
carrier-facing connections are a finding worth noting (typical for
premise-equipment SIP carriers, but document the deliberate choice).
---
## Live result snapshot (Bingham, 2026-04-25)
11 SIP trunks. All on `Main-Campus-Sub1-Pub-DP` except `exp-c-p-SIP-Trk`
(on `Pub-Sub1-DP`). All preferred codec is `711ulaw`. All destinations
on port 5060 (no TLS).
| Trunk | Destination | SIP Profile | CSS | Location |
|---|---|---|---|---|
| `Forward-Advantage-SIP-Trk` | 172.24.10.10 | FWD Advantage SIP Profile | National-CSS | Main-Campus-LOC |
| `PSTN-Router-SIP-Trk` | 172.20.6.222 | PSTN Sparklite SIP Profile | PSTN-Inbound-CSS | Hub_None |
| `RightFax-SIP-TRK` | 172.20.2.22 | RightFax SIP Profile | FAX-CSS | Hub_None |
| `Unity-Pub-SIP-TRK` | 172.20.6.104 | Unity SIP Profile | Internal-CSS | Main-Campus-LOC |
| `Unity-Sub-SIP-TRK` | 172.20.6.105 | Unity SIP Profile | Internal-CSS | Main-Campus-LOC |
| `VG450-SIP-TRK` | 172.20.6.99 | RightFax SIP Profile | National-CSS | Hub_None |
| `Verba-SIP-TRK` | 172.20.6.120 | Verba Profile | Internal-CSS | Hub_None |
| `ZetaFax-SIP-TRK` | 172.20.14.105 | ZetaFax SIP Profile | FAX-CSS | Hub_None |
| `exp-c-p-SIP-Trk` | exp-c-p.binghammemorial.org | Expressway SIP Profile | (none) | Main-Campus-LOC |
| `exp-c-s-SIP-Trk` | exp-c-s.binghammemorial.org | Expressway SIP Profile | (none) | Main-Campus-LOC |
| `singlewireFusion-SIP-TRK` | 172.20.6.114 | singlewire SIP Profile | (none) | Hub_None |
**Observations from this snapshot** (templates for what the prompt should
flag):
- `VG450-SIP-TRK` (analog voice gateway) shares the `RightFax SIP Profile`
with `RightFax-SIP-TRK`*probably intentional* (both terminate at fax
endpoints) but worth confirming with the operator.
- The 3 trunks with `calling_search_space = NULL` (Expressway-C primary,
Expressway-C secondary, singlewireFusion) all serve specific
device-only paths — they don't originate generic outbound routing. Not
a finding, but a useful invariant to call out.
- `PSTN-Router-SIP-Trk` is the only trunk with `Hub_None` location *and*
`PSTN-Inbound-CSS` *and* a stripped-down CSS — consistent with its role
as the carrier-facing trunk (and as the H1 SPOF in the
[route-plan audit](../../../docs/src/content/docs/audits/2026-04-25-cucm-route-plan.mdx)).
---
## Proposed prompt name and signature
```python
@mcp.prompt
def sip_trunk_report() -> str:
"""Comprehensive SIP trunk inventory: profiles, destinations,
downstream route-group membership, with findings template.
"""
...
```
Or with optional filtering:
```python
@mcp.prompt
def sip_trunk_report(name_filter: str | None = None) -> str:
"""SIP trunk inventory. Pass `name_filter` to narrow to one trunk
(substring match against device.name)."""
...
```
The body should embed the queries above, the follow-up tool-call list, and
the findings template — same pattern as `route_plan_overview`.
---
## Related
- Existing prompts (inline in `server.py`): `route_plan_overview`,
`investigate_pattern`, `audit_routing`
- Existing tool: `route_lists_and_groups()` — the right way to traverse
the trunk → RG → RL chain
- Existing tool: `route_devices_using_css(css_name)` — for follow-up
blast-radius analysis on each trunk's CSS
- Cisco data dictionary for CUCM 15: search via `cisco-docs` MCP for
"SIPDevice", "SIPTrunkDestination", "Device" tables

View File

@ -1,11 +1,29 @@
[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."
name = "mcaxl"
version = "2026.04.27"
description = "Read-only MCP server for Cisco Unified Communications Manager (CUCM) — AXL SOAP API + RisPort70 registration state — purpose-built for LLM-driven dial-plan and configuration auditing."
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.11"
keywords = [
"mcp", "cisco", "cucm", "axl", "risport",
"voip", "sip", "audit", "telephony",
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: System Administrators",
"Intended Audience :: Telecommunications Industry",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Communications :: Telephony",
"Topic :: System :: Networking :: Monitoring",
]
dependencies = [
"fastmcp>=3.2",
@ -22,14 +40,36 @@ test = [
]
[project.scripts]
mcp-cucm-axl = "mcp_cucm_axl.server:main"
mcaxl = "mcaxl.server:main"
[project.urls]
Homepage = "https://git.supported.systems/mcp/mcaxl"
Source = "https://git.supported.systems/mcp/mcaxl"
Issues = "https://git.supported.systems/mcp/mcaxl/issues"
Changelog = "https://git.supported.systems/mcp/mcaxl/src/branch/main/CHANGELOG.md"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/mcp_cucm_axl"]
packages = ["src/mcaxl"]
[tool.hatch.build.targets.sdist]
# Keep the published source distribution focused on what's needed to
# build / install / run. Excluded files exist for local development only.
exclude = [
"CLAUDE.md", # operator-private project context for Claude Code
".env", # never ship credentials
".env.local",
"axlsqltoolkit.zip", # Cisco-licensed; do not redistribute
"audits/", # cluster-specific audit reports
"tests/", # tests live in source repo, not the sdist
".pytest_cache/",
".ruff_cache/",
"dist/",
"build/",
]
[tool.ruff]
line-length = 100

5
src/mcaxl/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""mcaxl — read-only MCP server for CUCM 15 AXL."""
from .server import main
__all__ = ["main"]

206
src/mcaxl/cache.py Normal file
View File

@ -0,0 +1,206 @@
"""SQLite-backed TTL cache for AXL responses.
Keyed on (cluster_id, 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.
Hamilton review CRITICAL #2: cache key now includes a `cluster_id` so that
the same on-disk database can hold entries from multiple clusters without
silently serving cluster A's data when bound to cluster B. Operators who
swap `AXL_URL` between test and prod no longer see cross-cluster contamination.
"""
from __future__ import annotations
import json
import sqlite3
import time
from pathlib import Path
from typing import Any
# Split into TABLE_DDL (idempotent table creation) and INDEX_DDL (run AFTER
# any column-adding migration, so indexes that reference newer columns don't
# fail against legacy databases).
TABLE_DDL = """
CREATE TABLE IF NOT EXISTS axl_cache (
cache_key TEXT PRIMARY KEY,
cluster_id TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL,
args_json TEXT NOT NULL,
result_json TEXT NOT NULL,
created_at REAL NOT NULL,
expires_at REAL NOT NULL
);
"""
INDEX_DDL = """
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);
CREATE INDEX IF NOT EXISTS axl_cache_cluster_idx ON axl_cache(cluster_id);
"""
class AxlCache:
"""SQLite TTL cache. Thread-safe via per-call connections."""
def __init__(
self,
db_path: Path,
default_ttl: int,
cluster_id: str | None = None,
):
self.db_path = db_path
self.default_ttl = default_ttl
# Empty string when unset — matches the column DEFAULT and keeps
# SQL filtering simple. Pre-fix databases will have '' for legacy
# entries, which is fine: a server now passing cluster_id="prod"
# won't see them, which is the correct cautious behavior.
self.cluster_id = cluster_id or ""
self.db_path.parent.mkdir(parents=True, exist_ok=True)
with self._conn() as c:
# 1) Make sure table exists (no-op if already present)
c.executescript(TABLE_DDL)
# 2) Bring legacy schemas forward (adds cluster_id if missing)
self._migrate(c)
# 3) NOW create indexes — safe because all columns exist
c.executescript(INDEX_DDL)
@staticmethod
def _migrate(c: sqlite3.Connection) -> None:
"""Bring pre-existing databases up to the current schema.
`CREATE TABLE IF NOT EXISTS` is idempotent for table existence but
does not add columns to an already-existing table. Pre-fix caches
lack `cluster_id`; rather than failing the next INSERT with
`no such column`, we add it here. Defaults to '' which makes the
legacy entries belong to the "unknown cluster" invisible to any
new client passing an actual cluster_id, which is the cautious
outcome.
"""
cols = {row[1] for row in c.execute("PRAGMA table_info(axl_cache)").fetchall()}
if "cluster_id" not in cols:
c.execute(
"ALTER TABLE axl_cache ADD COLUMN cluster_id TEXT NOT NULL DEFAULT ''"
)
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
def _make_key(self, method: str, kwargs: dict) -> str:
# cluster_id prefix isolates entries by cluster identity. sort_keys
# gives us a deterministic key regardless of dict order.
return (
f"{self.cluster_id}::{method}::"
f"{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, cluster_id, method, args_json, result_json,
created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
key,
self.cluster_id,
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:
# Entries scoped to THIS cluster_id. The on-disk file may also
# contain entries from other clusters; those are intentionally
# invisible here.
total = c.execute(
"SELECT COUNT(*) FROM axl_cache WHERE cluster_id = ?",
(self.cluster_id,),
).fetchone()[0]
live = c.execute(
"SELECT COUNT(*) FROM axl_cache "
"WHERE cluster_id = ? AND expires_at > ?",
(self.cluster_id, now),
).fetchone()[0]
by_method = {
row[0]: row[1]
for row in c.execute(
"SELECT method, COUNT(*) FROM axl_cache "
"WHERE cluster_id = ? AND expires_at > ? "
"GROUP BY method ORDER BY 2 DESC",
(self.cluster_id, now),
).fetchall()
}
# Diagnostic: how many entries from OTHER clusters live in the
# same file. Useful for spotting an env-var swap that would
# otherwise be invisible.
foreign = c.execute(
"SELECT COUNT(*) FROM axl_cache WHERE cluster_id != ?",
(self.cluster_id,),
).fetchone()[0]
return {
"db_path": str(self.db_path),
"cluster_id": self.cluster_id,
"default_ttl_seconds": self.default_ttl,
"total_entries": total,
"live_entries": live,
"expired_entries": total - live,
"foreign_cluster_entries": foreign,
"by_method": by_method,
}
def clear(self, method_pattern: str | None = None) -> int:
# Only clears entries for THIS cluster — never touches a sibling
# cluster's cached data even if it lives in the same file.
with self._conn() as c:
if method_pattern:
cursor = c.execute(
"DELETE FROM axl_cache "
"WHERE cluster_id = ? AND method LIKE ?",
(self.cluster_id, method_pattern.replace("*", "%")),
)
else:
cursor = c.execute(
"DELETE FROM axl_cache WHERE cluster_id = ?",
(self.cluster_id,),
)
return cursor.rowcount
def purge_expired(self) -> int:
# Purges expired entries across ALL clusters in this file.
# Expired entries are never useful regardless of which cluster
# they belong to, so per-cluster scoping isn't needed here.
with self._conn() as c:
cursor = c.execute("DELETE FROM axl_cache WHERE expires_at <= ?", (time.time(),))
return cursor.rowcount

View File

@ -25,30 +25,69 @@ 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."""
"""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._connection_error: str | None = None
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
if self._connection_error is not None:
raise RuntimeError(self._connection_error)
# 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:
raise RuntimeError(
self._config_error = (
f"Missing required env var {e.args[0]}. "
f"Set AXL_URL, AXL_USER, AXL_PASS in .env or the environment."
) from None
)
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
@ -67,10 +106,37 @@ class AxlClient:
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 = Path(user_cache_dir("mcaxl")) / "zeep_wsdl.db"
zeep_cache_path.parent.mkdir(parents=True, exist_ok=True)
transport = Transport(
@ -91,14 +157,25 @@ class AxlClient:
"{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})",
f"[mcaxl] 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
# 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"[mcaxl] {self._last_error} (operational, will retry on next call)",
file=sys.stderr,
flush=True,
)
raise RuntimeError(self._last_error) from e
# ---- read-only operations ----

View File

@ -24,9 +24,11 @@ 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")
# No default path — operators set CISCO_DOCS_INDEX_PATH explicitly when
# they have a sibling cisco-docs index they want prompts to draw from.
# When unset, prompts gracefully degrade with a notice telling the LLM
# to use the cisco-docs MCP server's search_docs tool instead.
_DEFAULT_INDEX_DIR: Path | None = None
# Doc-name multipliers — higher = preferred for conceptual prompts.
@ -55,15 +57,27 @@ class DocsIndex:
@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)
)
# Resolution order:
# 1. Explicit index_dir argument (test/programmatic use)
# 2. CISCO_DOCS_INDEX_PATH env var
# 3. _DEFAULT_INDEX_DIR (None upstream — set by downstream forks)
# If none resolve to an existing index, prompts gracefully degrade.
if index_dir is None:
env_path = os.environ.get("CISCO_DOCS_INDEX_PATH")
if env_path:
index_dir = Path(env_path)
elif _DEFAULT_INDEX_DIR is not None:
index_dir = _DEFAULT_INDEX_DIR
else:
# No path configured — prompts will degrade with a notice
return None
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"[mcaxl] cisco-docs index not found at {index_dir}; "
f"prompts will run without schema enrichment.",
file=sys.stderr,
flush=True,
@ -77,7 +91,7 @@ class DocsIndex:
if line.strip()
]
print(
f"[mcp-cucm-axl] loaded {len(chunks)} doc chunks from {index_dir}",
f"[mcaxl] loaded {len(chunks)} doc chunks from {index_dir}",
file=sys.stderr,
flush=True,
)

View File

@ -0,0 +1,44 @@
"""Schema-grounded conversation seeds for `mcaxl`.
Each module here defines a `render(docs, *args, **kwargs) -> str` function
that produces the prompt body. The `@mcp.prompt` registration shims live
in `server.py` they're thin wrappers that pull the module-global `_docs`
and delegate. Keeping the rendering pure (no module globals, no FastMCP
imports here) makes prompts unit-testable in isolation and keeps each
prompt's content in its own file.
To add a new prompt:
1. Create `src/mcaxl/prompts/<name>.py` exporting a `render()`.
2. Re-export it below.
3. Add a thin `@mcp.prompt`-decorated shim in `server.py` that calls it.
Adding the shim is required because FastMCP introspects the *decorated*
function's signature to expose parameters to the LLM — the registration
shim is where the parameter contract lives.
"""
from . import (
audit_routing,
cucm_sql_help,
hunt_pilot_audit,
inbound_did_audit,
investigate_pattern,
phone_inventory_report,
route_plan_overview,
sip_trunk_report,
user_audit,
whoami,
)
__all__ = [
"audit_routing",
"cucm_sql_help",
"hunt_pilot_audit",
"inbound_did_audit",
"investigate_pattern",
"phone_inventory_report",
"route_plan_overview",
"sip_trunk_report",
"user_audit",
"whoami",
]

View File

@ -0,0 +1,73 @@
"""Shared helpers and constants used by multiple prompts."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
# Keyword sets for pulling relevant doc chunks. Tuned per audit topic so
# prompt enrichment focuses on the right schema docs without burning tokens
# on irrelevant CLI-reference material.
ROUTE_KEYWORDS = [
"route plan", "route pattern", "translation pattern",
"calling search space", "partition", "transformation",
"digit discard", "numplan", "routepartition",
]
AUDIT_KEYWORDS = {
"full": ROUTE_KEYWORDS,
"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",
],
"route_lists": [
"route list", "route group", "device pool", "local route group",
],
}
SIP_TRUNK_KEYWORDS = [
"sip trunk", "sip device", "sipdevice", "siptrunkdestination",
"sip profile", "trunk", "transport", "early offer",
]
def docs_or_empty_msg() -> str:
"""Fallback when the docs index isn't loaded — tells the LLM how to
get equivalent info via the sibling cisco-docs MCP server."""
return (
"_The cisco-docs index is not loaded. Set CISCO_DOCS_INDEX_PATH or "
"ensure `chunks.jsonl` + `index_meta.json` from the cisco-docs indexer. "
"You can also use the sibling `cisco-docs` MCP server's `search_docs` "
"tool for live semantic search._"
)
def render_schema_block(
docs: "DocsIndex | None",
keywords: list[str],
*,
max_chunks: int = 5,
max_chars_per_chunk: int = 1000,
) -> str:
"""Pull doc chunks for the given keywords and format them for embedding.
Returns a `docs_or_empty_msg()` notice when docs is None.
"""
if docs is None:
return docs_or_empty_msg()
chunks = docs.find(
keywords,
max_chunks=max_chunks,
max_chars_per_chunk=max_chars_per_chunk,
)
return docs.format_chunks_for_prompt(chunks)

View File

@ -0,0 +1,76 @@
"""Comprehensive routing audit walkthrough."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import AUDIT_KEYWORDS, ROUTE_KEYWORDS, render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
def render(docs: "DocsIndex | None", focus: str = "full") -> str:
"""Conduct a focused audit of the cluster's routing configuration.
Args:
focus: One of "full", "translations", "css_partitions",
"transformations", "route_lists". Tunes which schema chunks
get embedded.
"""
keyword_set = AUDIT_KEYWORDS.get(focus, ROUTE_KEYWORDS)
schema_block = render_schema_block(
docs, keyword_set, max_chunks=6, max_chars_per_chunk=900
)
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.
"""

View File

@ -0,0 +1,52 @@
"""Catch-all prompt for arbitrary CUCM SQL questions. Includes schema chunks."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
def render(docs: "DocsIndex | None", question: str) -> str:
"""Generate a SQL-help prompt seeded with chunks relevant to the question."""
keywords = [w for w in question.lower().split() if len(w) > 3][:8]
if keywords:
schema_block = render_schema_block(
docs, keywords, max_chunks=5, max_chars_per_chunk=900
)
else:
# Question has no substantive keywords — fall back to a generic
# data-dictionary primer rather than trying to embed nothing.
schema_block = render_schema_block(
docs,
["data dictionary", "informix", "schema"],
max_chunks=3,
max_chars_per_chunk=900,
)
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.
"""

View File

@ -0,0 +1,207 @@
"""Hunt pilot + line group + queue settings audit."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
_KEYWORDS = [
"hunt pilot", "hunt list", "line group", "distribution algorithm",
"queue", "rna", "ring no answer", "huntpilotqueue",
]
def render(docs: "DocsIndex | None") -> str:
"""Hunt pilot inventory and audit. Schema-aware (uses
`huntpilotqueue.fknumplan_pilot`, NOT `fknumplan` verified against
CUCM 15 schema 2026-04-25)."""
schema_block = render_schema_block(
docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900
)
return """# CUCM Hunt Pilot Audit
Hunt pilots distribute incoming calls across a group of phones (hunt
group) with configurable algorithms (top-down, longest-idle, broadcast,
etc.) and queue behavior. Misconfigured hunt pilots are a common source
of "calls disappear into the void" complaints.
## Schema (CUCM 15)
- `numplan` row with `tkpatternusage = 7` is a Hunt Pilot
- `huntpilotqueue` joins to numplan via `fknumplan_pilot` (NOT
`fknumplan` that's a common-mistake column name)
- `linegroup` defines the distribution algorithm and member-handling
- `linegroupnumplanmap` joins line groups to their member DNs
- A hunt pilot points to a "Route List" device (just like route
patterns do) that route list contains route groups containing
line groups containing phones
## Step 1 — Hunt pilot inventory
```sql
SELECT
np.dnorpattern AS hunt_pilot,
rp.name AS partition,
np.description,
np.calledpartytransformationmask AS xform_called,
np.callingpartytransformationmask AS xform_calling,
np.pkid
FROM numplan np
JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE np.tkpatternusage = 7 -- Hunt Pilot
ORDER BY rp.name, np.dnorpattern;
```
Each hunt pilot is a "front door" pattern what callers dial to reach
a group. The transformations columns matter: a hunt pilot may rewrite
the called party (e.g., normalize to a base-extension) before
distribution.
## Step 2 — Queue settings per hunt pilot
```sql
SELECT
np.dnorpattern AS hunt_pilot,
hpq.maxcallersinqueue,
hpq.maxwaittimeinqueue,
hpq.maxwaittimedestination AS overflow_dest,
hpq.noagentdestination AS no_agent_dest,
hpq.queuefulldestination AS queue_full_dest,
css_max.name AS css_for_max_wait,
css_no.name AS css_for_no_agent,
css_full.name AS css_for_queue_full
FROM huntpilotqueue hpq
JOIN numplan np ON hpq.fknumplan_pilot = np.pkid
LEFT OUTER JOIN callingsearchspace css_max ON hpq.fkcallingsearchspace_maxwaittime = css_max.pkid
LEFT OUTER JOIN callingsearchspace css_no ON hpq.fkcallingsearchspace_noagent = css_no.pkid
LEFT OUTER JOIN callingsearchspace css_full ON hpq.fkcallingsearchspace_pilotqueuefull = css_full.pkid
ORDER BY np.dnorpattern;
```
Queue behavior is the most-misconfigured aspect of hunt pilots:
- `maxwaittimeinqueue = 0` means "no max" callers can wait forever.
Usually a misconfiguration; should be set to a sensible value
(e.g., 30-300 seconds) with an overflow destination.
- `maxwaittimedestination` / `noagentdestination` /
`queuefulldestination` define what happens when each condition
triggers. NULL on any of these means "drop the call" almost never
the operator's intent.
## Step 3 — Line groups and their member DNs
```sql
SELECT
lg.name AS line_group,
ta.name AS distribution_algorithm,
np.dnorpattern AS member_dn,
rp.name AS member_partition,
lgnpm.lineselectionorder AS sortorder
FROM linegroup lg
LEFT OUTER JOIN typedistributealgorithm ta ON lg.tkdistributealgorithm = ta.enum
LEFT OUTER JOIN linegroupnumplanmap lgnpm ON lgnpm.fklinegroup = lg.pkid
LEFT OUTER JOIN numplan np ON lgnpm.fknumplan = np.pkid
LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
ORDER BY lg.name, lgnpm.lineselectionorder;
```
Distribution algorithms (decoded via `typedistributealgorithm`):
- **Top Down** first member always rings first; predictable but
uneven load.
- **Circular** round-robin starting after the last-rung member.
- **Longest Idle Time** call goes to the member who hasn't rung in
the longest time. Most common for fairness.
- **Broadcast** every member rings simultaneously. Use sparingly;
noisy.
## Step 4 — Hunt pilot → route list / line group destinations
The hunt pilot routes to a Route List that contains the actual line
group(s). Use the existing tool:
```
route_inspect_pattern(<hunt_pilot_dn>, <partition>)
```
This returns the destination chain. For each hunt pilot identified in
Step 1, run this to confirm:
- Destination is the expected route list / line group
- The line group's distribution algorithm matches operational intent
- Member DNs all exist and belong to active phones (cross-reference
with `phone_inventory_report`)
## Step 5 — Hunt pilots with no line group destination (dead pilots)
```sql
-- Hunt pilots that don't appear in any line group routing
SELECT np.dnorpattern, rp.name AS partition, np.description
FROM numplan np
JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE np.tkpatternusage = 7
AND NOT EXISTS (
SELECT 1 FROM devicenumplanmap dnm
JOIN device d ON dnm.fkdevice = d.pkid
JOIN typeclass tc ON d.tkclass = tc.enum
WHERE dnm.fknumplan = np.pkid AND tc.name = 'Route List'
)
ORDER BY rp.name, np.dnorpattern;
```
A hunt pilot that doesn't route to a route list is functionally dead —
calls match the pilot pattern but have nowhere to go. Often vestigial
config.
## Findings to call out
### Queue misconfigurations
- **`maxwaittimeinqueue = 0`** without an explicit overflow rationale
callers can wait forever in queue.
- **NULL `maxwaittimedestination` / `noagentdestination` /
`queuefulldestination`** calls drop without going anywhere.
Recommend explicit destinations (typically voicemail).
- **Mismatched CSSs** on the three queue destinations: the CSSs control
whether the destination is reachable. If the CSS is overly restrictive,
the overflow destination might not be reachable from the hunt pilot's
context.
### Line group hygiene
- **Empty line groups** (no members in `linegroupnumplanmap`) calls
would never ring anywhere.
- **Line groups with one member** fine, but check whether a hunt
pilot is overkill (a direct DN may be simpler).
- **Members in an inactive partition** or pointing to a DN that's
no longer assigned to any phone.
### Distribution algorithm clarity
- **Distribution = Broadcast** for a large group operationally noisy;
confirm with operator whether this is intentional.
- **Distribution = Top Down** with the same first member every time
load concentrates on one phone; operator may want longest-idle.
### Coverage gaps
- **Hunt pilots not pointing to a route list** (Step 5) cleanup.
- **Hunt pilot description missing** operationally opaque; recommend
description annotations.
## Suggested follow-up calls
- `route_inspect_pattern(<pilot>, <partition>)` for each hunt pilot to
trace its destination chain.
- `route_devices_using_css(<each unique queue-destination CSS>)` to
understand the blast radius of the CSSs the queue uses.
- `axl_describe_table('huntpilotqueue')` if the audit needs additional
queue-config columns (announcement, MoH source, etc.).
## Reference: CUCM data dictionary (hunt pilots, line groups)
""" + schema_block + """
Run the queries above and produce a structured findings report. Group
by hunt pilot; under each, list its queue config, line group(s), and
distribution algorithm; then list the audit findings with severity.
"""

View File

@ -0,0 +1,181 @@
"""Inbound DID inventory: XFORM-Inbound-DNIS, screening, executed routing."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
_KEYWORDS = [
"translation pattern", "called party transformation", "DNIS",
"inbound", "route pattern", "transformation mask", "PSTN",
]
def render(docs: "DocsIndex | None") -> str:
"""Inventory of every PSTN DID that *might* be presented to the cluster,
cross-referenced against actual routing destinations.
Pattern: turn the operator-curated XFORM-Inbound-DNIS list into a
"presentable DID inventory" with destination cross-checks. Surfaces
orphan target extensions, undocumented DIDs, and the silent-fallback
catch-all hazard.
"""
schema_block = render_schema_block(
docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900
)
return """# CUCM Inbound DID Audit
The cluster's inbound architecture has multiple transformation layers.
This audit reconstructs the full set of DIDs that "should" be presentable
and cross-checks them against actual routing destinations.
## Conceptual model (verify on this cluster)
Inbound calls usually traverse:
1. PSTN-facing trunk (typically `PSTN-Router-SIP-Trk` or similar)
trunk's primary CSS, e.g. `PSTN-Inbound-CSS`.
2. `PSTN-Inbound-PT` partition with a `!` translation that re-routes to
`PSTN-Screen-CSS` for spam filtering.
3. `PSTN-Screen-PT` with operator-curated `block_enabled=true` translation
patterns for spam blocking, plus a `!` / `\\+!` catch-all that
re-routes via `Internal-CSS`.
4. `Internal-CSS` finds the destination either a 10-digit route pattern
in `Internal-PT` (typically routes to fax trunks) or a directory
number after some upstream transformation.
The XFORM-Inbound-DNIS partition holds 100+ "Called Party Number
Transformation" patterns (`tkpatternusage = 20`) that map external DIDs
to internal extensions. **Whether they fire automatically depends on
the trunk's `fkcallingsearchspace_cntdpntransform` setting** — confirm
this with the operator or via:
```sql
SELECT d.name, sd.fkcallingsearchspace_cntdpntransform
FROM device d JOIN sipdevice sd ON sd.fkdevice = d.pkid
JOIN typeclass tc ON d.tkclass = tc.enum
WHERE tc.name = 'Trunk';
```
## Step 1 — Operator-curated DID inventory (XFORM-Inbound-DNIS)
```sql
SELECT
np.dnorpattern AS inbound_did,
np.calledpartytransformationmask AS xform_to,
np.prefixdigitsout AS prefix_out,
np.description
FROM numplan np
JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE rp.name = 'XFORM-Inbound-DNIS'
ORDER BY np.dnorpattern;
```
Categorize the result:
- **Pass-through 10-digit** (`xform_to IS NULL`, description "remain as 10-digit"):
the DID is expected to match a corresponding route pattern in `Internal-PT`.
- **Block-translation to 4-digit** (`xform_to LIKE '7XXX'` or similar):
rewrites last N digits to an internal extension range.
- **Specific renames** (literal `xform_to` like `7400`, `2523`):
one-off DID-to-extension mappings, often legacy clinic numbers.
- **Wildcard ranges** (pattern contains `[N-M]`, `XXX`, `.`):
efficient block mappings worth reviewing for bounds correctness.
- **Catch-all `!`**: dangerous silent fallback if present flag it.
## Step 2 — Actually-executed routing (Internal-PT route patterns ≥7 digits)
```sql
SELECT np.dnorpattern, np.description, np.blockenable
FROM numplan np
JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE rp.name = 'Internal-PT'
AND np.tkpatternusage = 5 -- Route pattern
AND LENGTH(np.dnorpattern) >= 7
ORDER BY np.dnorpattern;
```
Cross-check:
- Every "remain as 10-digit" DID from Step 1 should have a matching
10-digit route pattern here.
- Every route pattern here should have a known purpose (typically routes
to a fax trunk or hunt list).
## Step 3 — Spam blocklist (operator-curated, audit-relevant)
```sql
SELECT np.dnorpattern, np.description
FROM numplan np
JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE rp.name = 'PSTN-Screen-PT' AND np.blockenable = 't'
ORDER BY np.dnorpattern;
```
Spam blocklists rotate every 30-60 days as campaigns shift. This is
informational, but persistently-stale entries (descriptions like
"Number spamming X's phone" from 1+ year ago) can be retired.
## Step 4 — Verify target extensions exist
For each unique target extension referenced in Step 1's renames, confirm
it actually exists as a real DN:
```sql
-- Replace '7400, 2523, 2532, ...' with the unique targets from Step 1
SELECT FIRST 50 np.dnorpattern, np.description, rp.name AS partition
FROM numplan np
JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE np.tkpatternusage = 2 -- Directory Number
AND np.dnorpattern IN ('7400', '2523', '2532', '...')
ORDER BY np.dnorpattern;
```
A DID that translates to an extension that doesn't exist is a *broken*
inbound DID calls succeed in the transformation step but fail at the
final DN lookup.
## Findings to call out
- **Orphan target extensions**: any DID that translates to a non-existent
DN. High-severity audit finding caller hears "number not found" or
reorder.
- **`!` catch-all in XFORM-Inbound-DNIS**: silently rewrites unrecognized
DIDs to their last 4 digits. Recommend documenting or removing at
minimum, surface the risk.
- **Old clinic / legacy DIDs**: if multiple DIDs forward to the same
internal extension with descriptions referencing legacy locations,
confirm the target is a hunt pilot or reception line, not a single
user receiving misdirected calls.
- **Block-of-N pass-through declarations** (e.g., `20878594XX` declares
100 DIDs): verify carrier allocation matches the block size. Excess
declarations are harmless but noisy.
- **Spam blocklist hygiene**: > 6-month-old entries that are no longer
active campaigns are cleanup candidates.
- **Stale block_enabled outbound DIDs** (`Internal-PT` route patterns
with `blockenable = 't'`): confirm intentional. Single oddballs among
many similar route patterns warrant a description annotation.
## Suggested follow-up calls
- `route_inspect_pattern(<DID>)` for any specific DID where the audit
needs the full route trace (CSS reachability, destination chain).
- `axl_sql("SELECT * FROM numplan WHERE dnorpattern = '<target ext>'")`
to verify each rewrite target exists.
- `route_devices_using_css('PSTN-Inbound-CSS')` to confirm the inbound
trunk(s) using this CSS should typically be just 1.
## Reference: CUCM data dictionary (translation patterns)
""" + schema_block + """
Run the queries above and produce a structured findings report. Group
by DID handling category (Step 1 categorization), then list orphan-
target findings and other hygiene issues. Don't enumerate all 100+
DIDs group by exchange (208-XXX-) and call out exceptions.
"""

View File

@ -0,0 +1,60 @@
"""Deep-dive seed for one specific pattern. Schema chunks embedded."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
_KEYWORDS = ["numplan", "transformation", "translation pattern", "route pattern"]
def render(
docs: "DocsIndex | None",
pattern: str,
partition: str | None = None,
) -> str:
"""Walk the user through a single pattern in detail."""
schema_block = render_schema_block(
docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900
)
partition_clause = f" in partition `{partition}`" if partition else ""
inspect_args = f"pattern={pattern!r}"
if partition:
inspect_args += f", partition={partition!r}"
return f"""# Investigate Pattern: `{pattern}`{partition_clause}
Walk the user through this pattern in detail.
## Suggested calls
1. `route_inspect_pattern({inspect_args})`
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}
"""

View File

@ -0,0 +1,236 @@
"""Phone inventory + audit findings: device pools, CSSs, owners, anomalies."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
_KEYWORDS = [
"phone", "device", "device pool", "calling search space",
"model", "owner", "extension mobility",
]
def _esc(s: str) -> str:
return s.replace("'", "''")
def render(docs: "DocsIndex | None", filter: str | None = None) -> str:
"""Phone inventory: counts by class, distribution by pool/CSS/model,
plus findings around orphans, drift, and naming anomalies.
Args:
filter: Optional substring (case-sensitive LIKE on `device.name`
OR `device.description`) to narrow the inventory.
"""
schema_block = render_schema_block(
docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900
)
if filter:
safe = _esc(filter)
name_clause = f"\n AND (d.name LIKE '%{safe}%' OR d.description LIKE '%{safe}%')"
scope_note = f"narrowed to phones matching `%{filter}%`"
else:
name_clause = ""
scope_note = "all phones"
return f"""# CUCM Phone Inventory — {scope_note}
Produce a phone inventory with audit-relevant groupings and findings.
The cluster typically has hundreds-to-thousands of phones; this prompt
asks for *aggregations and anomalies*, not a flat dump.
## Step 1 — Aggregate counts (always run first)
```sql
SELECT tm.name AS model, COUNT(*) AS phone_count
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN typemodel tm ON d.tkmodel = tm.enum
WHERE tc.name = 'Phone'{name_clause}
GROUP BY tm.name ORDER BY 2 DESC;
```
```sql
SELECT dp.name AS device_pool, COUNT(*) AS phone_count
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN devicepool dp ON d.fkdevicepool = dp.pkid
WHERE tc.name = 'Phone'{name_clause}
GROUP BY dp.name ORDER BY 2 DESC;
```
```sql
SELECT css.name AS css_name, COUNT(*) AS phone_count
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN callingsearchspace css ON d.fkcallingsearchspace = css.pkid
WHERE tc.name = 'Phone'{name_clause}
GROUP BY css.name ORDER BY 2 DESC;
```
These three give the shape of the fleet. The CSS one in particular is
audit-critical: most phones should land in a small number of CSSs
(typically `National-CSS` for outbound dialing on this kind of cluster).
*Outliers* in the CSS distribution warrant individual inspection.
## Step 2 — Anomaly queries (the audit-actionable findings)
### Phones with no description or auto-generated descriptions
```sql
SELECT FIRST 50 d.name, d.description, dp.name AS device_pool
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN devicepool dp ON d.fkdevicepool = dp.pkid
WHERE tc.name = 'Phone'{name_clause}
AND (d.description IS NULL
OR d.description = ''
OR d.description = d.name
OR d.description LIKE 'AN%' || SUBSTRING(d.name FROM 3) || '%')
ORDER BY d.name;
```
(Phones whose description matches or echoes their MAC address /
auto-generated name. Hospital deployments accumulate these as patient-
room or mobile carts that were quickly added without operator notes.)
### Phones with no associated owner
```sql
SELECT FIRST 50 d.name, d.description, dp.name AS device_pool
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN devicepool dp ON d.fkdevicepool = dp.pkid
WHERE tc.name = 'Phone'{name_clause}
AND d.fkenduser IS NULL
ORDER BY d.name;
```
(Some unassigned phones are intentional common areas, guest phones,
patient rooms. Operator should verify that the count matches expected
shared-use endpoints.)
### Phones in non-default CSS (where "default" means most-common)
After Step 1's CSS distribution query, identify the most common CSS,
then list phones NOT in it:
```sql
SELECT FIRST 50 d.name, d.description, css.name AS css_name
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN callingsearchspace css ON d.fkcallingsearchspace = css.pkid
WHERE tc.name = 'Phone'{name_clause}
AND (css.name != '<MOST_COMMON_CSS>' OR css.name IS NULL)
ORDER BY css.name, d.name;
```
(Replace `<MOST_COMMON_CSS>` with the result from Step 1.)
### Phones with no primary CSS (NULL)
```sql
SELECT d.name, d.description, dp.name AS device_pool
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
LEFT OUTER JOIN devicepool dp ON d.fkdevicepool = dp.pkid
WHERE tc.name = 'Phone'{name_clause} AND d.fkcallingsearchspace IS NULL
ORDER BY d.name;
```
(NULL CSS means the phone inherits from device pool. Usually fine, but
worth confirming the count matches expectations a sudden increase
indicates config drift.)
## Step 3 — Cross-reference with real-time registration (highest-leverage)
`device_registration_summary()` returns a cluster-wide breakdown by status
across all device classes. The audit-relevant question is: what fraction
of *configured* phones are *actually registered*?
```
device_registration_summary()
```
For drill-down on the unregistered population:
```
device_registration_status(device_class="Phone", status="UnRegistered")
```
This gives you the actual list of phones that have a config in CUCM but
are not currently registered. **A phone that's been "UnRegistered" for
weeks is the strongest orphan signal we can produce** cleaner than
"no description" or "no owner" because registration state reflects
real-world usage, not just operator hygiene.
**Findings to surface from this cross-reference:**
- **Configured-but-never-registered phones**: the cluster has them
configured, they've never come online. Often abandoned conference
phones, decommissioned analog gateways, or templates that were
cloned but never deployed. Strong candidates for deletion.
- **Registered but no description / no owner**: actively-used phones
whose operator hygiene is weak. Hospitals accumulate these as
"patient room 3142" or shared cordless phones; verify they're
intentional shared-use endpoints rather than just untracked.
- **PartiallyRegistered**: a phone that's communicating with one CM
node but not another. Indicates a real registration problem
(firewall, version mismatch, certificate); flag for IT.
- **Status mismatch**: phone class says "Cisco 7841" but `model` field
on the registered device says something different could indicate a
spoofing attempt or hardware swap without config update.
## Step 4 — Cross-reference with users (if owners exist)
For phones with an owner, list owner identities to spot stale
assignments (employee left, phone still tagged to them):
```sql
SELECT FIRST 50 d.name, d.description, eu.userid, eu.lastname, eu.status
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
JOIN enduser eu ON d.fkenduser = eu.pkid
WHERE tc.name = 'Phone'{name_clause}
ORDER BY d.name;
```
`enduser.status` of `0` = inactive; `1` = active. A phone owned by an
inactive user is a finding.
## Findings to call out
- **Naming hygiene gaps**: phones with auto-generated or empty descriptions.
Recommend: operational follow-up to add room/owner/purpose notes.
- **CSS sprawl**: if more than ~3-5 CSSs are in regular use, justify each.
Phones in single-purpose CSSs (e.g., `911CER-CSS` with only 9 devices)
usually have specific reasons document them.
- **Orphan owners**: phones owned by inactive users. Recommend reassignment
or unassignment.
- **Model heterogeneity**: a wide spread of phone models indicates ongoing
refresh or drift. Worth knowing for firmware/upgrade planning.
- **Devicepool concentration**: if 99%+ of phones are in one device pool,
that's the pool that matters for any DP-related change. Flag it as a
high-blast-radius asset.
## Suggested follow-up calls
- `route_devices_using_css(css_name=<each CSS from Step 1>)` to map the
full impact of changing any phone-attached CSS.
- `axl_describe_table('device')` if the LLM needs additional columns
(firmware load, MAC, security profile, etc.).
- `axl_sql("SELECT name FROM typemodel WHERE enum IN (...)")` to decode
any unfamiliar model codes.
## Reference: CUCM data dictionary (devices)
{schema_block}
Run the queries above and produce a structured inventory report:
counts table, distribution tables, anomaly findings with severity, and
a recommendations section. Don't enumerate every phone — focus on
aggregates and exceptions.
"""

View File

@ -0,0 +1,51 @@
"""Snapshot of the cluster's routing setup, with schema reference embedded."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import ROUTE_KEYWORDS, render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
def render(docs: "DocsIndex | None") -> str:
"""Use this when starting a fresh route-plan audit conversation."""
schema_block = render_schema_block(docs, ROUTE_KEYWORDS, max_chunks=5)
return f"""# CUCM Route Plan Overview
You are auditing the routing configuration of a CUCM 15 cluster via the
`mcaxl` 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.
"""

View File

@ -0,0 +1,161 @@
"""Comprehensive SIP trunk inventory: profiles, destinations, route-group
membership, and findings template.
Implementation reference: `docs/query-patterns/sip-trunk-report.md`. The
query patterns embedded below are the validated forms from that doc;
update them in lockstep when the schema knowledge evolves.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import SIP_TRUNK_KEYWORDS, render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
def _esc(s: str) -> str:
"""Inline string-literal escape — same convention as route_plan._esc.
Single quotes get doubled for Informix; everything else passes through.
"""
return s.replace("'", "''")
def render(docs: "DocsIndex | None", name_filter: str | None = None) -> str:
"""Produce a SIP trunk inventory + findings prompt.
Args:
name_filter: If given, narrow the inventory to trunks whose name
matches the substring (case-sensitive LIKE). Otherwise
includes all trunks.
"""
schema_block = render_schema_block(
docs, SIP_TRUNK_KEYWORDS, max_chunks=4, max_chars_per_chunk=900
)
# Build the LIKE clause for the name filter, if provided. The filter
# value is escaped for SQL safety, then wrapped in `%`-bookends for
# substring matching against device.name.
if name_filter:
safe = _esc(name_filter)
name_clause = f"\n AND d.name LIKE '%{safe}%'"
scope_note = f"narrowed to trunks matching `%{name_filter}%`"
else:
name_clause = ""
scope_note = "all SIP trunks"
return f"""# CUCM SIP Trunk Report — {scope_note}
Produce a comprehensive SIP trunk inventory: profiles, destinations,
downstream route-group membership, and a findings analysis. The queries
below are the validated forms from `docs/query-patterns/sip-trunk-report.md`.
## Step 1 — Trunk inventory (one row per trunk)
```sql
SELECT
d.name AS trunk_name,
d.description,
sp.name AS sip_profile,
css.name AS calling_search_space,
dp.name AS device_pool,
loc.name AS location,
tsc.name AS preferred_codec,
sd.requesturidomainname AS sip_domain,
sd.isanonymous AS anon_caller_id,
sd.preferrouteheaderdestination AS prefer_route_header,
sd.acceptinboundrdnis AS accept_inbound_rdnis,
sd.acceptoutboundrdnis AS accept_outbound_rdnis
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
JOIN sipdevice sd ON sd.fkdevice = d.pkid
LEFT JOIN sipprofile sp ON d.fksipprofile = sp.pkid
LEFT JOIN callingsearchspace css ON d.fkcallingsearchspace = css.pkid
LEFT JOIN devicepool dp ON d.fkdevicepool = dp.pkid
LEFT JOIN location loc ON d.fklocation = loc.pkid
LEFT JOIN typesipcodec tsc ON sd.tksipcodec = tsc.enum
WHERE tc.name = 'Trunk'{name_clause}
ORDER BY d.name;
```
Run via `axl_sql(query=<the SQL above>)`.
`anon_caller_id`, `prefer_route_header`, and the RDNIS flags return `'t'`
or `'f'` (Informix bool encoding). Render with that in mind.
## Step 2 — Destinations (one row per IP/port; trunks can have multiple)
```sql
SELECT
d.name AS trunk_name,
std.address,
std.port,
std.sortorder
FROM siptrunkdestination std
JOIN sipdevice sd ON std.fksipdevice = sd.pkid
JOIN device d ON sd.fkdevice = d.pkid{name_clause.replace("d.name LIKE", "d.name LIKE")}
ORDER BY d.name, std.sortorder;
```
`address` may be an IP literal *or* a DNS name (Expressway-C trunks often
use FQDNs). `port` defaults to 5060 (UDP/TCP) or 5061 (TLS).
## Step 3 — Route-group / route-list membership
Don't write raw SQL — use the existing tool:
```
route_lists_and_groups()
```
Filter the result for `route_groups[].devices[].class == "Trunk"` to find
the (trunk route group route list) triples. Note: route lists with
**route groups that have no static device members** resolve at call-time
via the calling phone's device-pool `fkroutegroup_local` mapping (CUCM
Standard Local Route Group feature). Trunks reachable only through Local
Route Groups won't appear in the static result — call
`route_device_pool_route_groups()` to enumerate those.
## Step 4 — Findings to call out
After the data is gathered, produce findings on each axis:
- **Single-point-of-failure trunks**: route groups with one trunk member
where that group is the only path for a critical pattern (911,
voicemail, fax). Cross-reference with `route_lists_and_groups()`.
- **Profile sprawl vs. consolidation**: are N trunks using N different
SIP profiles, or do most share a small number? Sprawl = harder to
audit transport/timing settings consistently.
- **CSS asymmetry**: PSTN-facing inbound trunks should typically have
restrictive CSSs; internal-facing trunks (voicemail) should have
permissive ones. Mismatches can cause one-way audio or routing failures.
- **Codec heterogeneity**: most clusters standardize on G.711 µ-law.
Trunks advertising G.722 or G.729 first warrant explanation.
- **DNS-vs-IP destinations**: trunks using FQDNs depend on cluster DNS;
flag if FQDN resolution adds an unsurfaced SPOF.
- **Security posture**: trunks using `Non Secure SIP Trunk Profile` for
carrier-facing connections are typical for premise SIP carriers but
worth documenting as a deliberate choice.
- **NULL primary CSS** on trunks is *not* automatically a finding
Expressway-C and InformaCast Fusion trunks legitimately have it
(they don't originate generic outbound routing).
## Suggested follow-up calls
- `route_devices_using_css(css_name=<each unique trunk CSS>)` what else
uses the same CSS as a particular trunk? Identifies shared dependencies.
- `route_inspect_pattern(pattern, partition)` for each route pattern
that targets a trunk-bearing route list, walk the call path.
- `axl_sql("SELECT name, description FROM sipprofile")` when multiple
trunks share a SIP profile, look up the profile detail once.
## Reference: CUCM data dictionary (SIP trunk-related tables)
{schema_block}
Run the queries above, then produce a structured trunk-by-trunk report
followed by the findings analysis. Use markdown tables for the inventory
section; reserve prose for findings and recommendations.
"""

View File

@ -0,0 +1,204 @@
"""End user + application user audit: roles, group memberships, security."""
from __future__ import annotations
from typing import TYPE_CHECKING
from ._common import render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
_KEYWORDS = [
"end user", "application user", "directory group", "function role",
"role", "permission", "authentication", "ldap",
]
# Focus → rationale + which audit checklist sections to emphasize
_FOCUSES = {
"full",
"admin", # users with admin/serviceability roles
"inactive", # users with status != active
"app_users", # service accounts
}
def render(docs: "DocsIndex | None", focus: str = "full") -> str:
"""User audit: end users + application users, role assignments,
security-relevant findings.
Args:
focus: One of "full", "admin", "inactive", "app_users". Tunes
which checklist sections the report emphasizes; all queries
still run for context.
"""
if focus not in _FOCUSES:
focus = "full"
schema_block = render_schema_block(
docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900
)
return f"""# CUCM User Audit — focus: `{focus}`
End users (`enduser`), application users (`applicationuser`), and their
role assignments via the dirgroup/functionrole join chain. Security and
operational hygiene findings.
## Step 1 — Headcounts
End users grouped by status:
```sql
SELECT status, COUNT(*) AS user_count FROM enduser GROUP BY status;
```
Application users (no status column they're configuration objects, not
human accounts):
```sql
SELECT COUNT(*) AS app_user_count FROM applicationuser;
```
`enduser.status`: `1` = active, `0` = inactive.
(Informix's `UNION ALL` rejects mixed-type columns even with `CAST`,
so two separate queries is the simplest portable form.)
## Step 2 — End user inventory
```sql
SELECT FIRST 100
eu.userid,
eu.firstname,
eu.lastname,
eu.displayname,
eu.status,
eu.userrank,
eu.islocaluser
FROM enduser eu
ORDER BY eu.lastname, eu.firstname;
```
`islocaluser` = `t` means CCM-local, `f` means LDAP-synced. Mixing both is
common (admins local, hospital staff LDAP-synced).
`userrank` is a 1-5 integer that gates access to features by rank;
elevated ranks (4-5) usually correspond to admin populations.
## Step 3 — Application users (service accounts)
```sql
SELECT au.name, au.userrank, au.isstandard
FROM applicationuser au
ORDER BY au.userrank DESC, au.name;
```
`isstandard = 't'` is a built-in account (Cisco-shipped); `f` is operator-
created. Operator-created application users with high userrank are the
audit-critical population (these are typically API-access service accounts).
## Step 4 — Role assignments
End users / app users dirgroups function roles:
```sql
-- End users with their group memberships
SELECT FIRST 200
eu.userid,
eu.lastname,
dg.name AS dirgroup_name
FROM enduser eu
JOIN enduserdirgroupmap eudgm ON eudgm.fkenduser = eu.pkid
JOIN dirgroup dg ON eudgm.fkdirgroup = dg.pkid
WHERE dg.isstandard = 'f' OR dg.name LIKE '%Standard CCM%Admin%' OR dg.name LIKE '%Super%'
ORDER BY eu.userid, dg.name;
```
```sql
-- App users with their group memberships (always audit-relevant)
SELECT au.name AS app_user, dg.name AS dirgroup_name
FROM applicationuser au
JOIN applicationuserdirgroupmap audgm ON audgm.fkapplicationuser = au.pkid
JOIN dirgroup dg ON audgm.fkdirgroup = dg.pkid
ORDER BY au.name, dg.name;
```
```sql
-- Dirgroups function roles (the actual permissions)
SELECT dg.name AS group_name, fr.name AS role_name, fr.description
FROM dirgroup dg
JOIN functionroledirgroupmap frdgm ON frdgm.fkdirgroup = dg.pkid
JOIN functionrole fr ON frdgm.fkfunctionrole = fr.pkid
WHERE dg.isstandard = 'f' OR dg.name LIKE '%Admin%' OR dg.name LIKE '%Super%'
ORDER BY dg.name, fr.name;
```
The audit question is: who has admin-grade role membership, and is each
assignment justified?
## Step 5 — Phone owners (cross-reference)
Phones tagged to inactive users:
```sql
SELECT FIRST 50
d.name AS phone_name,
d.description,
eu.userid AS owner_userid,
eu.lastname,
eu.status AS owner_status
FROM device d
JOIN typeclass tc ON d.tkclass = tc.enum
JOIN enduser eu ON d.fkenduser = eu.pkid
WHERE tc.name = 'Phone' AND eu.status = '0'
ORDER BY eu.lastname, d.name;
```
A phone owned by an inactive user (departed employee, expired account)
is config drift either reassign or unassign.
## Findings to call out
### Security-critical
- **Application users with admin/superuser group membership**: each one
is an API key with elevated privileges. Document why each exists.
- **End users with `Standard CCM Super Users` or similar privileged
group memberships**: these are the people who can write-modify the
cluster. Confirm the population matches the documented admin team.
- **`islocaluser = 't'` accounts with admin privileges**: bypass LDAP/SSO,
may have static passwords. High-priority security review.
- **Application users with default Cisco-shipped passwords**: these don't
show in the schema (no plaintext access), but the existence of standard
app users (e.g., `CCMAdministrator`, `CCMSysUser`) with operator-set
passwords is worth a separate check via the GUI or a pen test.
### Operational hygiene
- **Inactive users (status = 0) still in groups**: cleanup candidates.
- **Phones owned by inactive users**: reassign or unassign.
- **Users with no group membership**: likely just have basic phone
access; not a finding by itself but worth confirming if a sudden
population appears.
### Drift indicators
- **End users created locally vs LDAP-synced ratio**: if local-user count
has grown over time, indicates either an LDAP sync failure or
out-of-band account creation.
- **Operator-created dirgroups (`isstandard = 'f'`)**: list and verify each
has a documented purpose.
## Suggested follow-up calls
- For each app user with API/admin access, run
`axl_sql("SELECT * FROM applicationuser WHERE name = '<name>'")` to
inspect ACL flags (`acl*` columns).
- `axl_describe_table('enduser')` for additional fields (manager,
department, etc. if LDAP populates them).
- `axl_sql("SELECT name, description FROM dirgroup ORDER BY isstandard, name")`
to see all groups including standard Cisco-shipped ones.
## Reference: CUCM data dictionary (users + roles)
{schema_block}
Run the queries above and produce a structured findings report. The
focus parameter (`{focus}`) means: emphasize the corresponding section
in the writeup, but include all sections for context.
"""

192
src/mcaxl/prompts/whoami.py Normal file
View File

@ -0,0 +1,192 @@
"""Look up a user's role chain — defaults to the calling AXL service account.
Audit-relevant self-diagnostic: "what does this MCP server's account
*actually* have access to via AXL?" Identifies the access control group
membership chain (user dirgroup functionrole) and surfaces the
combination of roles, including any that contradict the group's name
(e.g., a "ReadOnly-XXX" group that contains a full RW role).
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from ._common import render_schema_block
if TYPE_CHECKING:
from ..docs_loader import DocsIndex
_KEYWORDS = [
"application user", "end user", "function role", "directory group",
"access control group", "role assignment", "AXL access",
]
def _esc(s: str) -> str:
return s.replace("'", "''")
def render(docs: "DocsIndex | None", userid: str | None = None) -> str:
"""Look up the role chain for a userid (or the AXL account by default).
Args:
userid: User identity to look up. If None, defaults to the value
of the `AXL_USER` environment variable (the account this MCP
server uses to talk to the cluster). Names are case-sensitive
in SQL, so the value must match the cluster's stored form
exactly operator might need to verify case via
`axl_sql("SELECT name FROM applicationuser WHERE LOWER(name) LIKE '%X%'")`.
"""
schema_block = render_schema_block(
docs, _KEYWORDS, max_chunks=4, max_chars_per_chunk=900
)
# Default to the AXL service account if no userid given
target = userid or os.environ.get("AXL_USER")
if not target:
target_clause = "<set userid parameter or AXL_USER env var>"
scope_note = "no userid supplied"
else:
target = _esc(target)
target_clause = target
scope_note = (
f"AXL service account (`AXL_USER`)" if userid is None
else f"explicit userid `{userid}`"
)
return f"""# whoami — role chain for `{target_clause}` ({scope_note})
Resolve the access-control-group function-role chain for a single
account. Defaults to the AXL service account so the operator can quickly
see what permissions THIS MCP server actually has.
## Schema knowledge (CUCM 15)
CUCM stores user-to-role mapping in two parallel hierarchies:
```
enduser enduserdirgroupmap dirgroup
functionroledirgroupmap
functionrole
applicationuser applicationuserdirgroupmap dirgroup ... (same)
```
A user's effective roles are the UNION of all roles attached to all
groups they belong to. Cisco docs sometimes call dirgroups "Access
Control Groups" — same thing, different name.
**Note on table naming**: older Cisco docs reference
`enduserauthgroupmap` and `dirgrouprolemap`; CUCM 15 uses
`enduserdirgroupmap` and `functionroledirgroupmap`. The queries below
use the verified CUCM 15 names.
## Step 1 — applicationuser lookup (most AXL service accounts are here)
```sql
SELECT au.name AS userid,
g.name AS access_control_group,
fr.name AS role
FROM applicationuser au
LEFT OUTER JOIN applicationuserdirgroupmap audgm ON audgm.fkapplicationuser = au.pkid
LEFT OUTER JOIN dirgroup g ON g.pkid = audgm.fkdirgroup
LEFT OUTER JOIN functionroledirgroupmap grm ON grm.fkdirgroup = g.pkid
LEFT OUTER JOIN functionrole fr ON fr.pkid = grm.fkfunctionrole
WHERE au.name = '{target_clause}'
ORDER BY g.name, fr.name;
```
## Step 2 — enduser lookup (run if Step 1 returns 0 rows)
```sql
SELECT u.userid,
g.name AS access_control_group,
fr.name AS role
FROM enduser u
LEFT OUTER JOIN enduserdirgroupmap egm ON egm.fkenduser = u.pkid
LEFT OUTER JOIN dirgroup g ON g.pkid = egm.fkdirgroup
LEFT OUTER JOIN functionroledirgroupmap grm ON grm.fkdirgroup = g.pkid
LEFT OUTER JOIN functionrole fr ON fr.pkid = grm.fkfunctionrole
WHERE u.userid = '{target_clause}'
ORDER BY g.name, fr.name;
```
## Step 3 — case-insensitive fallback (if both return 0 rows)
CUCM's authentication is case-insensitive for usernames, but SQL
lookups are case-sensitive. If neither query returns rows, search
case-insensitively to find the canonical stored form:
```sql
SELECT 'applicationuser' AS source, name AS canonical_name
FROM applicationuser WHERE LOWER(name) = LOWER('{target_clause}')
UNION
SELECT 'enduser' AS source, userid AS canonical_name
FROM enduser WHERE LOWER(userid) = LOWER('{target_clause}');
```
Then re-run Step 1 or Step 2 with the canonical name.
## Findings to call out
### Misnamed access control groups
- A group named `ReadOnly-XXX` containing the role
`Standard AXL API Access` (the full read-write role, NOT
`Standard AXL Read Only API Access`) is a misconfiguration the
group's name implies read-only intent but the membership grants RW.
- Confirm by checking whether the same group ALSO contains the proper
read-only role; if both, the group is contradictory and the role
set should be reduced.
### High-privilege roles to flag specifically
- `Standard CCM Super Users` full admin write access. Should be
reserved for human admin accounts, not service accounts.
- `Standard AXL API Access` full RW AXL access. Service accounts
for read-only tooling should have `Standard AXL Read Only API Access`
instead.
- `Standard Packet Sniffing` captures call-setup traffic. In
healthcare/regulated environments, may capture PHI in SIP headers.
- `Standard CCM Admin Users` admin write access via CCM Admin UI;
combined with API roles, gives a service account broad reach.
### Excess permission accumulation
- Service accounts with > 3-4 roles often indicate "added permissions
over time without removing old ones." Review and prune.
- An MCP-server-style account should ideally have ONLY
`Standard AXL Read Only API Access` (and nothing else).
### Operational notes
- The `.env` value of AXL_USER may differ in *case* from the cluster's
canonical stored form. AXL auth is case-insensitive, but SQL is not.
Recommend the .env value match the canonical stored form exactly.
## Suggested follow-up calls
- For each high-privilege role found, check who *else* has it:
`axl_sql("SELECT au.name FROM applicationuser au
JOIN applicationuserdirgroupmap m ON m.fkapplicationuser = au.pkid
JOIN functionroledirgroupmap r ON r.fkdirgroup = m.fkdirgroup
JOIN functionrole fr ON fr.pkid = r.fkfunctionrole
WHERE fr.name = '<role>'")`.
- `axl_describe_table('applicationuser')` for ACL flag columns
(`acl*` controls SIP subscription / OOD-refer / etc. additional
privileges beyond the role assignments).
## Reference: CUCM data dictionary (users + role chain)
{schema_block}
Run Step 1 first. If empty rows, run Step 2. If still empty, run Step 3
to find the canonical case form. Then produce a structured report:
- Account identity (and what type applicationuser vs enduser)
- Access control groups
- Effective roles (deduplicated)
- Findings, with severity, especially flagging misnamed groups and
excess privilege.
"""

407
src/mcaxl/risport.py Normal file
View File

@ -0,0 +1,407 @@
"""RisPort70 — real-time device registration status.
CUCM's RisPort (Real-time Information Server Port) lives at
`/realtimeservice2/services/RISService70` and exposes a SOAP API for
querying the *runtime* state of devices: are phones currently
registered, what IP did they get, what's their status, etc.
This is **complementary to AXL**: AXL tells us what's CONFIGURED;
RisPort tells us what's HAPPENING right now. For audit purposes the
difference between "configured but never registered" (orphan) and
"actively registered" (live) is the highest-value cross-reference.
Why we ship our own RisPort wrapper alongside the AXL one rather than
deferring to a separate operations MCP server (`@calltelemetry/cisco-cucm-mcp`
covers operational debugging more thoroughly): the audit-narrative is
the use case here. `phone_inventory_report` becomes substantively more
valuable when it can join configured-phones with currently-registered
state in a single prompt, and that join lives most naturally in this
codebase.
SOAP envelope structure cribbed from cisco-cucm-mcp's TypeScript
implementation (MIT licensed) namespaces and field names verified
against CUCM 15.0.1.12900 documentation.
"""
from __future__ import annotations
import os
import re
import sys
import xml.etree.ElementTree as ET
from urllib.parse import urlparse
from requests import Session
from requests.adapters import HTTPAdapter
from requests.auth import HTTPBasicAuth
from urllib3.util.retry import Retry
# RisPort path on the CUCM publisher
_RIS_PATH = "/realtimeservice2/services/RISService70"
# SOAP namespaces. These match Cisco's published values for RisPort70.
_NS_SOAPENV = "http://schemas.xmlsoap.org/soap/envelope/"
_NS_RIS = "http://schemas.cisco.com/ast/soap"
# Status values RisPort returns for devices
DEVICE_STATUS_VALUES = (
"Any", "Registered", "UnRegistered", "Rejected",
"PartiallyRegistered", "Unknown",
)
def _escape_xml(s: str) -> str:
"""Minimal XML entity escape for values we inject into SOAP envelopes."""
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
)
def _build_select_envelope(
state_info: str = "",
max_devices: int = 200,
device_class: str = "Phone",
status: str = "Any",
select_items: list[str] | None = None,
select_by: str = "Name",
) -> str:
"""Build a `selectCmDevice` SOAP envelope.
The structure is fragile the CmSelectionCriteria child elements
must appear in the order Cisco's WSDL expects, and missing fields
are rejected. We err on the side of always including every field
with sensible defaults.
"""
items = select_items if select_items else ["*"]
items_xml = "".join(
f"<soap:item><soap:Item>{_escape_xml(i)}</soap:Item></soap:item>"
for i in items
)
return (
'<?xml version="1.0" encoding="utf-8"?>'
f'<soapenv:Envelope xmlns:soapenv="{_NS_SOAPENV}" xmlns:soap="{_NS_RIS}">'
"<soapenv:Header/>"
"<soapenv:Body>"
"<soap:selectCmDevice>"
f"<soap:StateInfo>{_escape_xml(state_info)}</soap:StateInfo>"
"<soap:CmSelectionCriteria>"
f"<soap:MaxReturnedDevices>{int(max_devices)}</soap:MaxReturnedDevices>"
f"<soap:DeviceClass>{_escape_xml(device_class)}</soap:DeviceClass>"
"<soap:Model>255</soap:Model>"
f"<soap:Status>{_escape_xml(status)}</soap:Status>"
"<soap:NodeName></soap:NodeName>"
f"<soap:SelectBy>{_escape_xml(select_by)}</soap:SelectBy>"
f"<soap:SelectItems>{items_xml}</soap:SelectItems>"
"<soap:Protocol>Any</soap:Protocol>"
"<soap:DownloadStatus>Any</soap:DownloadStatus>"
"</soap:CmSelectionCriteria>"
"</soap:selectCmDevice>"
"</soapenv:Body>"
"</soapenv:Envelope>"
)
def _extract_text(elem: ET.Element | None, tag: str) -> str:
"""Return the text of <tag> child of elem, or '' if missing.
RisPort responses don't use namespaces consistently across CUCM
versions; matching on local-name only is more robust than xpath
against a fixed namespace.
"""
if elem is None:
return ""
for child in elem:
if child.tag.split("}")[-1] == tag:
return (child.text or "").strip()
return ""
def _extract_ip(elem: ET.Element | None) -> str:
"""Pull the IP string out of CUCM's nested IPAddress structure.
CUCM 15 returns IPAddress as a nested struct:
<IPAddress><item><IP>x.x.x.x</IP><IPAddrType>ipv4</IPAddrType></item></IPAddress>
Older versions may return a flat string. We handle both.
"""
if elem is None:
return ""
if elem.text and elem.text.strip():
return elem.text.strip()
for item_elem in elem:
if item_elem.tag.split("}")[-1].lower() == "item":
for ip_elem in item_elem:
if ip_elem.tag.split("}")[-1] == "IP":
return (ip_elem.text or "").strip()
return ""
def _parse_device(elem: ET.Element) -> dict:
"""Pull the audit-relevant fields out of a single CmDevice element."""
ip_elem = None
for child in elem:
local = child.tag.split("}")[-1]
if local in ("IPAddress", "IpAddress"):
ip_elem = child
break
return {
"name": _extract_text(elem, "Name"),
"ip_address": _extract_ip(ip_elem),
"description": _extract_text(elem, "Description"),
"dir_number": _extract_text(elem, "DirNumber"),
"status": _extract_text(elem, "Status"),
"status_reason": _extract_text(elem, "StatusReason"),
"protocol": _extract_text(elem, "Protocol"),
"model": _extract_text(elem, "Model"),
"active_load_id": _extract_text(elem, "ActiveLoadID"),
"timestamp": _extract_text(elem, "TimeStamp"),
}
def _parse_response(xml_text: str) -> dict:
"""Parse the selectCmDevice SOAP response into structured form.
Returns:
{
"total_devices_found": int,
"state_info": str (cursor for next page; empty = last),
"cm_nodes": [
{"name": str, "return_code": str, "devices": [...]}
],
}
"""
root = ET.fromstring(xml_text)
# Walk to selectCmDeviceReturn regardless of namespacing quirks
select_return = None
for elem in root.iter():
if elem.tag.split("}")[-1] == "selectCmDeviceReturn":
select_return = elem
break
if select_return is None:
# Check for SOAP fault
for elem in root.iter():
if elem.tag.split("}")[-1] == "Fault":
fault_string = _extract_text(elem, "faultstring")
raise RuntimeError(f"RisPort SOAP fault: {fault_string or 'unknown'}")
raise RuntimeError("RisPort response missing selectCmDeviceReturn")
# CUCM 15 wraps the data in an extra <SelectCmDeviceResult> element
# inside <selectCmDeviceReturn>. Older versions exposed the fields
# directly. Probe for the wrapper and descend if found.
for child in select_return:
if child.tag.split("}")[-1] == "SelectCmDeviceResult":
select_return = child
break
total = _extract_text(select_return, "TotalDevicesFound")
state_info = _extract_text(select_return, "StateInfo")
nodes_out = []
cm_nodes = None
for child in select_return:
if child.tag.split("}")[-1] == "CmNodes":
cm_nodes = child
break
if cm_nodes is not None:
for node_elem in cm_nodes:
if node_elem.tag.split("}")[-1] != "item":
continue
node_name = _extract_text(node_elem, "Name")
return_code = _extract_text(node_elem, "ReturnCode")
devices: list[dict] = []
for cm_devices_elem in node_elem:
if cm_devices_elem.tag.split("}")[-1] != "CmDevices":
continue
for dev_item in cm_devices_elem:
if dev_item.tag.split("}")[-1] == "item":
devices.append(_parse_device(dev_item))
nodes_out.append({
"name": node_name,
"return_code": return_code,
"devices": devices,
})
try:
total_int = int(total)
except (TypeError, ValueError):
total_int = 0
return {
"total_devices_found": total_int,
"state_info": state_info,
"cm_nodes": nodes_out,
}
class RisPortClient:
"""Lazy SOAP client for CUCM's RisPort70 service.
Reuses AXL_URL / AXL_USER / AXL_PASS env vars (RisPort lives on the
same host as AXL on standard CUCM deployments). Builds a separate
`requests.Session` so the retry policy and TLS settings can be tuned
independently if needed.
"""
def __init__(self):
self._session: Session | None = None
self._url: str | None = None
self._config_error: str | None = None
self._last_error: str | None = None
def _ensure_session(self) -> None:
if self._session is not None:
return
if self._config_error is not None:
raise RuntimeError(self._config_error)
try:
axl_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]} for RisPort. "
f"Reuses AXL_URL/USER/PASS."
)
raise RuntimeError(self._config_error) from None
verify_tls = os.environ.get("AXL_VERIFY_TLS", "false").lower() in (
"1", "true", "yes"
)
# Derive RisPort URL from AXL host
parsed = urlparse(axl_url)
if not parsed.hostname:
self._config_error = f"Could not parse AXL_URL host: {axl_url!r}"
raise RuntimeError(self._config_error)
port = parsed.port or 8443
scheme = parsed.scheme or "https"
self._url = f"{scheme}://{parsed.hostname}:{port}{_RIS_PATH}"
session = Session()
session.verify = verify_tls
session.auth = HTTPBasicAuth(user, password)
# Same retry policy as AXL — 503/502/504 with backoff
max_retries = int(os.environ.get("AXL_RATE_LIMIT_RETRIES", "3"))
if max_retries > 0:
retry = Retry(
total=max_retries,
backoff_factor=1.0,
status_forcelist=(502, 503, 504),
allowed_methods=frozenset(["POST", "GET"]),
raise_on_status=False,
respect_retry_after_header=True,
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
self._session = session
print(
f"[mcaxl] RisPort client ready: {self._url}",
file=sys.stderr,
flush=True,
)
def select_cm_device(
self,
max_devices: int = 200,
device_class: str = "Phone",
status: str = "Any",
name_filter: str | None = None,
state_info: str = "",
) -> dict:
"""Single-page selectCmDevice call. Returns up to max_devices rows.
For full inventory, call `select_all` which auto-paginates via
the `state_info` cursor.
"""
# Validate before connecting — we want a clear error from bad input
# whether or not env vars are set.
if status not in DEVICE_STATUS_VALUES:
raise ValueError(
f"status must be one of {DEVICE_STATUS_VALUES}; got {status!r}"
)
self._ensure_session()
select_items = [name_filter] if name_filter else ["*"]
envelope = _build_select_envelope(
state_info=state_info,
max_devices=max_devices,
device_class=device_class,
status=status,
select_items=select_items,
)
try:
resp = self._session.post(
self._url,
data=envelope,
headers={
"Content-Type": "text/xml; charset=utf-8",
"SOAPAction": '"selectCmDevice"',
},
timeout=30,
)
resp.raise_for_status()
self._last_error = None
return _parse_response(resp.text)
except Exception as e:
self._last_error = f"RisPort request failed: {e}"
raise RuntimeError(self._last_error) from e
def select_all(
self,
device_class: str = "Phone",
status: str = "Any",
page_size: int = 200,
max_pages: int = 20,
) -> dict:
"""Auto-paginate through selectCmDevice using the StateInfo cursor.
Walks pages until the cursor is empty OR max_pages reached. Returns
a single dict with all devices flattened across pages, plus
per-status counts and a `pages_walked` field for diagnostics.
"""
all_devices: list[dict] = []
nodes_seen: set[str] = set()
state_info = ""
pages = 0
last_total = 0
while pages < max_pages:
page = self.select_cm_device(
max_devices=page_size,
device_class=device_class,
status=status,
state_info=state_info,
)
pages += 1
last_total = page["total_devices_found"]
for node in page["cm_nodes"]:
nodes_seen.add(node["name"])
all_devices.extend(node["devices"])
next_cursor = page.get("state_info") or ""
if not next_cursor or next_cursor == state_info:
break
state_info = next_cursor
# Per-status breakdown
status_counts: dict[str, int] = {}
for d in all_devices:
s = d.get("status") or "Unknown"
status_counts[s] = status_counts.get(s, 0) + 1
return {
"device_class": device_class,
"status_filter": status,
"total_devices_found": last_total,
"devices_returned": len(all_devices),
"pages_walked": pages,
"cm_nodes_seen": sorted(nodes_seen),
"status_counts": status_counts,
"devices": all_devices,
}

View File

@ -342,12 +342,31 @@ def list_route_lists_and_groups(client: "AxlClient", name: str | None = None) ->
def _to_int(v: object) -> int | None:
"""Cast Informix string-encoded integers to int; passthrough None/non-numeric."""
"""Cast Informix string-encoded integers to int; passthrough None/non-numeric.
Hamilton review MINOR #6: a non-numeric value from the cluster (data
corruption, unexpected schema change, type drift across CUCM versions)
used to silently become None and downstream sort logic that defaulted
None to 0 would jumble the failover order with no warning. We still
return None on bad data (the caller's error path is unchanged), but we
log the offending value to stderr so an operator notices something
weird is happening at the data layer.
None itself is a valid value (column not set in CUCM) and produces no
warning only genuinely-unparseable values trigger the log.
"""
if v is None:
return None
try:
return int(v)
except (TypeError, ValueError):
import sys
print(
f"[mcaxl] _to_int: unexpected non-numeric value {v!r} "
f"(type {type(v).__name__}); returning None",
file=sys.stderr,
flush=True,
)
return None
@ -411,9 +430,74 @@ def list_device_pool_route_groups(
# 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 points: each entry maps a category label to a (table,
# column, sql) spec. The SQL returns rows with a common shape: `name`
# and `context` (partition for patterns, device class for devices, etc.),
# plus a `description` where the source table has one.
#
# Issue #1: previously we only enumerated a handful of device columns;
# trunks referencing CSSs via `fkcallingsearchspace_cgpntransform` and
# `_cdpntransform` produced false-zero impact analysis. The set below
# covers all 71 known fkcallingsearchspace_* columns from the CUCM 15
# schema; tests/test_css_impact.py:test_complete_schema_coverage_against_known_columns
# fires red if a future CUCM version adds a column we haven't added.
#
# Templates for the common cases follow; the dict is built from them.
def _device_query(suffix: str) -> dict:
"""Reference query for a `fkcallingsearchspace[_<suffix>]` column on `device`."""
col = "fkcallingsearchspace" if not suffix else f"fkcallingsearchspace_{suffix}"
return {
"table": "device",
"column": col,
"sql": f"""
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.{col} = '{{pkid}}'
""",
}
def _devicepool_query(suffix: str) -> dict:
"""Reference query for a fkcallingsearchspace_<suffix> column on devicepool.
Phones / devices in a DP inherit these CSSs unless overridden. Audit-
relevant: a CSS only assigned via DP inheritance was previously
invisible (this is the gap the M1 caveat in today's audit reports
explicitly called out, now closed).
Note: `devicepool` has no `description` column (verified against CUCM
15 schema 2026-04-26); the query selects only `name`.
"""
col = f"fkcallingsearchspace_{suffix}"
return {
"table": "devicepool",
"column": col,
"sql": f"""
SELECT name FROM devicepool WHERE {col} = '{{pkid}}'
""",
}
def _numplan_query(suffix: str) -> dict:
"""Reference query for a fkcallingsearchspace_<suffix> column on numplan.
These are the line-level forwarding CSSs (CFA/CFB/CFNA/CFUR and their
internal/personal variants), MWI, translation, etc. Hits one row per
DN that has the CSS configured for that scenario.
"""
col = f"fkcallingsearchspace_{suffix}"
return {
"table": "numplan",
"column": col,
"sql": f"""
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.{col} = '{{pkid}}'
""",
}
_CSS_REFERENCE_QUERIES: dict[str, dict] = {
# Line-level forwarding CSSs (call-forward variants on a DN)
"line_call_forward_all_css": {
@ -517,6 +601,241 @@ _CSS_REFERENCE_QUERIES: dict[str, dict] = {
WHERE rl.fkcallingsearchspace = '{pkid}'
""",
},
# PRIMARY device CSS — the field the GUI sets when you assign "Calling
# Search Space" to a phone, gateway, or trunk. Not suffixed; spelled out
# as fkcallingsearchspace (no _xxx). This is where most CSS assignments
# actually live; missing it produced false-zero impact analyses.
"device_primary_css": {
"table": "device", "column": "fkcallingsearchspace",
"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 = '{pkid}'
""",
},
"device_cgpn_unknown_css": {
"table": "device", "column": "fkcallingsearchspace_cgpnunknown",
"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_cgpnunknown = '{pkid}'
""",
},
# Line-level CSS for phone-line monitoring (BLF, presence)
"line_monitoring_css": {
"table": "devicenumplanmap", "column": "fkcallingsearchspace_monitoring",
"sql": """
SELECT d.name AS name, np.dnorpattern AS context, d.description AS description
FROM devicenumplanmap dnm
JOIN device d ON dnm.fkdevice = d.pkid
JOIN numplan np ON dnm.fknumplan = np.pkid
WHERE dnm.fkcallingsearchspace_monitoring = '{pkid}'
""",
},
# H.323 / SIP gateway called-party transformation CSSs
"gateway_h323_called_xform_css": {
"table": "h323device", "column": "fkcallingsearchspace_cntdpntransform",
"sql": """
SELECT d.name AS name, tc.name AS context, d.description AS description
FROM h323device h
JOIN device d ON h.fkdevice = d.pkid
LEFT OUTER JOIN typeclass tc ON d.tkclass = tc.enum
WHERE h.fkcallingsearchspace_cntdpntransform = '{pkid}'
""",
},
"gateway_sip_called_xform_css": {
"table": "sipdevice", "column": "fkcallingsearchspace_cntdpntransform",
"sql": """
SELECT d.name AS name, tc.name AS context, d.description AS description
FROM sipdevice s
JOIN device d ON s.fkdevice = d.pkid
LEFT OUTER JOIN typeclass tc ON d.tkclass = tc.enum
WHERE s.fkcallingsearchspace_cntdpntransform = '{pkid}'
""",
},
# Hunt pilot queue CSSs (max-wait, no-agent, queue-full destinations)
"huntpilot_max_wait_css": {
"table": "huntpilotqueue", "column": "fkcallingsearchspace_maxwaittime",
"sql": """
SELECT np.dnorpattern AS name, rp.name AS context, np.description AS description
FROM huntpilotqueue hpq
JOIN numplan np ON hpq.fknumplan_pilot = np.pkid
LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE hpq.fkcallingsearchspace_maxwaittime = '{pkid}'
""",
},
"huntpilot_no_agent_css": {
"table": "huntpilotqueue", "column": "fkcallingsearchspace_noagent",
"sql": """
SELECT np.dnorpattern AS name, rp.name AS context, np.description AS description
FROM huntpilotqueue hpq
JOIN numplan np ON hpq.fknumplan_pilot = np.pkid
LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE hpq.fkcallingsearchspace_noagent = '{pkid}'
""",
},
"huntpilot_queue_full_css": {
"table": "huntpilotqueue", "column": "fkcallingsearchspace_pilotqueuefull",
"sql": """
SELECT np.dnorpattern AS name, rp.name AS context, np.description AS description
FROM huntpilotqueue hpq
JOIN numplan np ON hpq.fknumplan_pilot = np.pkid
LEFT OUTER JOIN routepartition rp ON np.fkroutepartition = rp.pkid
WHERE hpq.fkcallingsearchspace_pilotqueuefull = '{pkid}'
""",
},
# ----------------------------------------------------------------
# Issue #1 fix: comprehensive coverage of all fkcallingsearchspace_*
# columns from the CUCM 15 schema. Categories below use the
# _device_query / _devicepool_query / _numplan_query helpers above
# for the bulk cases; the remaining tables use hand-written SQL.
# ----------------------------------------------------------------
# device — remaining 11 variants beyond the 6 already covered above
"device_aar_css": _device_query("aar"),
"device_called_intl_css": _device_query("calledintl"),
"device_called_national_css": _device_query("callednational"),
"device_called_subscriber_css": _device_query("calledsubscriber"),
"device_called_unknown_css": _device_query("calledunknown"),
"device_cdpn_xform_css": _device_query("cdpntransform"), # issue #1
"device_cgpn_ingress_dn_css": _device_query("cgpningressdn"),
"device_cgpn_intl_css": _device_query("cgpnintl"),
"device_cgpn_national_css": _device_query("cgpnnational"),
"device_cgpn_subscriber_css": _device_query("cgpnsubscriber"),
"device_cgpn_xform_css": _device_query("cgpntransform"), # issue #1
# devicepool — 17 variants. Phones inherit these CSSs from their DP
# unless overridden. Important: a CSS only assigned via DP inheritance
# was previously invisible (the M1 caveat in today's audit reports).
"devicepool_aar_css": _devicepool_query("aar"),
"devicepool_adjunct_css": _devicepool_query("adjunct"),
"devicepool_autoregistration_css": _devicepool_query("autoregistration"),
"devicepool_called_intl_css": _devicepool_query("calledintl"),
"devicepool_called_national_css": _devicepool_query("callednational"),
"devicepool_called_subscriber_css": _devicepool_query("calledsubscriber"),
"devicepool_called_unknown_css": _devicepool_query("calledunknown"),
"devicepool_cdpn_xform_css": _devicepool_query("cdpntransform"),
"devicepool_cgpn_ingress_dn_css": _devicepool_query("cgpningressdn"),
"devicepool_cgpn_intl_css": _devicepool_query("cgpnintl"),
"devicepool_cgpn_national_css": _devicepool_query("cgpnnational"),
"devicepool_cgpn_subscriber_css": _devicepool_query("cgpnsubscriber"),
"devicepool_cgpn_xform_css": _devicepool_query("cgpntransform"),
"devicepool_cgpn_unknown_css": _devicepool_query("cgpnunknown"),
"devicepool_cntdpn_xform_css": _devicepool_query("cntdpntransform"),
"devicepool_mobility_css": _devicepool_query("mobility"),
"devicepool_rdn_transform_css": _devicepool_query("rdntransform"),
# numplan — remaining 13 forwarding/transformation variants
"line_call_forward_busy_int_css": _numplan_query("cfbint"),
"line_call_forward_hr_css": _numplan_query("cfhr"),
"line_call_forward_hr_int_css": _numplan_query("cfhrint"),
"line_call_forward_no_answer_int_css": _numplan_query("cfnaint"),
"line_call_forward_unregistered_int_css": _numplan_query("cfurint"),
"line_device_failure_css": _numplan_query("devicefailure"),
"line_mwi_css": _numplan_query("mwi"),
"line_personal_call_forward_css": _numplan_query("pff"),
"line_personal_call_forward_int_css": _numplan_query("pffint"),
"line_park_monitor_fwd_no_retrieve_css": _numplan_query("pkmonfwdnoret"),
"line_park_monitor_fwd_no_retrieve_int_css": _numplan_query("pkmonfwdnoretint"),
"line_reroute_css": _numplan_query("reroute"),
"line_revert_css": _numplan_query("revert"),
# site — single primary CSS for site-level (SAF) call routing.
# `site` has no name column; the human label comes from typesite.
"site_css": {
"table": "site", "column": "fkcallingsearchspace",
"sql": """
SELECT ts.name AS name, dp.name AS context, '' AS description
FROM site s
LEFT OUTER JOIN typesite ts ON s.tksite = ts.enum
LEFT OUTER JOIN devicepool dp ON s.fkdevicepool = dp.pkid
WHERE s.fkcallingsearchspace = '{pkid}'
""",
},
# External call control profile — diversion/rerouting CSS
# (`externalcallcontrolprofile` has no description column)
"external_call_control_diversion_css": {
"table": "externalcallcontrolprofile",
"column": "fkcallingsearchspace_diversionrerouting",
"sql": """
SELECT name FROM externalcallcontrolprofile
WHERE fkcallingsearchspace_diversionrerouting = '{pkid}'
""",
},
# Recording profile — call recording CSS
# (`recordingprofile` has no description column)
"recording_call_recording_css": {
"table": "recordingprofile",
"column": "fkcallingsearchspace_callrecording",
"sql": """
SELECT name FROM recordingprofile
WHERE fkcallingsearchspace_callrecording = '{pkid}'
""",
},
# Usage profile — blocking CSS
"usage_blocking_css": {
"table": "usageprofile", "column": "fkcallingsearchspace_blocking",
"sql": """
SELECT name, description FROM usageprofile
WHERE fkcallingsearchspace_blocking = '{pkid}'
""",
},
# VIPR E.164 transformation profiles
"vipre164_outgoing_cdpn_xform_css": {
"table": "vipre164transformation",
"column": "fkcallingsearchspace_outgoingcdpntranf",
"sql": """
SELECT name, description FROM vipre164transformation
WHERE fkcallingsearchspace_outgoingcdpntranf = '{pkid}'
""",
},
"vipre164_outgoing_cgpn_xform_css": {
"table": "vipre164transformation",
"column": "fkcallingsearchspace_outgoingcgpntranf",
"sql": """
SELECT name, description FROM vipre164transformation
WHERE fkcallingsearchspace_outgoingcgpntranf = '{pkid}'
""",
},
# Incoming transformation profile — 4 number-type variants
"incoming_xform_intl_css": {
"table": "incomingtransformationprofile",
"column": "fkcallingsearchspace_intl",
"sql": """
SELECT name, description FROM incomingtransformationprofile
WHERE fkcallingsearchspace_intl = '{pkid}'
""",
},
"incoming_xform_national_css": {
"table": "incomingtransformationprofile",
"column": "fkcallingsearchspace_national",
"sql": """
SELECT name, description FROM incomingtransformationprofile
WHERE fkcallingsearchspace_national = '{pkid}'
""",
},
"incoming_xform_subscriber_css": {
"table": "incomingtransformationprofile",
"column": "fkcallingsearchspace_subscriber",
"sql": """
SELECT name, description FROM incomingtransformationprofile
WHERE fkcallingsearchspace_subscriber = '{pkid}'
""",
},
"incoming_xform_unknown_css": {
"table": "incomingtransformationprofile",
"column": "fkcallingsearchspace_unknown",
"sql": """
SELECT name, description FROM incomingtransformationprofile
WHERE fkcallingsearchspace_unknown = '{pkid}'
""",
},
}
@ -595,11 +914,22 @@ def find_devices_using_css(
total_returned = sum(c.get("returned_count", 0) for c in grouped.values())
any_truncated = any(c.get("truncated") for c in grouped.values())
# Hamilton review MAJOR #3: per-category errors must propagate to the
# top-level summary, otherwise an LLM consuming `total_returned: 47`
# has no way to know that 5 categories errored and the real count is
# higher. "Software that understands itself reports its own degradation."
error_categories = sorted(
label for label, cat in grouped.items() if "error" in cat
)
complete = len(error_categories) == 0
return {
"css_name": css_name,
"css_pkid": css_pkid,
"total_returned": total_returned,
"any_truncated": any_truncated,
"complete": complete,
"categories_with_errors": len(error_categories),
"error_categories": error_categories,
"max_per_category": max_per_category,
"references_by_category": grouped,
}
@ -682,20 +1012,31 @@ def list_route_filters(
# Wildcard pattern matcher (better translation_chain)
# ====================================================================
# Hamilton review MAJOR #4: bound the digit-count of `!` and `@` wildcards
# to prevent catastrophic regex backtracking on adjacent-quantifier patterns.
# CUCM dial strings are practically capped well below this; 50 is a generous
# upper bound that keeps the regex's complexity polynomial.
_MAX_BANG_DIGITS = 50
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
! one or more digits (bounded see _MAX_BANG_DIGITS)
. terminator separator (after-dot digits get discarded by PreDot DDI)
[0-9] character class (passes through to regex unchanged)
[0-9] character class
*, # literal special-keypad symbols
\+ literal + (escaped in CUCM)
@ NANPA route filter represented as `\d+` here (we don't model the filter)
@ NANPA route filter represented as bounded \d{1,N} here
We escape regex metachars except those CUCM uses literally as wildcards.
Hamilton review MAJOR #4: an unclosed `[` (e.g., `[0-9` with no closing
bracket) used to silently fall through to treating the bracket as a
literal. That produced wrong matches with no warning. We now raise
ValueError so the caller can surface the malformed pattern explicitly.
"""
bounded_digits = "\\d{1," + str(_MAX_BANG_DIGITS) + "}"
out = []
i = 0
while i < len(pattern):
@ -703,22 +1044,24 @@ def _wildcard_to_regex(pattern: str) -> str:
if c == "X":
out.append(r"\d")
elif c == "!":
out.append(r"\d+")
out.append(bounded_digits)
elif c == "@":
# NANPA — would normally apply a route filter; treat as "any digits"
out.append(r"\d+")
out.append(bounded_digits)
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 ]
# Character class — copy through up to `]`. An unclosed bracket
# is a malformed pattern; raise so the caller knows.
j = pattern.find("]", i)
if j == -1:
out.append(re.escape(c))
i += 1
continue
raise ValueError(
f"Unclosed character-class bracket in pattern {pattern!r} "
f"at position {i}"
)
out.append(pattern[i:j + 1])
i = j
elif c == "\\" and i + 1 < len(pattern):
@ -732,9 +1075,18 @@ def _wildcard_to_regex(pattern: str) -> str:
def _pattern_matches_number(pattern: str, number: str) -> bool:
"""Test whether a CUCM dial pattern matches a number string."""
"""Test whether a CUCM dial pattern matches a number string.
Returns False on any compilation error (malformed pattern, unclosed
bracket, etc.) so a single bad pattern doesn't crash the entire
translation_chain query. The bad pattern is surfaced separately
via the error reporting in the response.
"""
try:
regex = _wildcard_to_regex(pattern)
except ValueError:
return False
try:
return re.match(regex, number) is not None
except re.error:
return False

View File

@ -29,12 +29,14 @@ from . import route_plan
from .cache import AxlCache
from .client import AxlClient
from .docs_loader import DocsIndex
from .risport import RisPortClient
# ---- Module-level singletons, initialized in main() ----
_cache: AxlCache | None = None
_axl: AxlClient | None = None
_docs: DocsIndex | None = None
_ris: RisPortClient | None = None
mcp = FastMCP("CUCM AXL (read-only)")
@ -46,15 +48,6 @@ def _client() -> AxlClient:
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
# ====================================================================
@ -108,29 +101,62 @@ def axl_describe_table(table_name: str) -> dict:
return _client().describe_informix_table(table_name)
def _require_cache() -> AxlCache:
"""Hamilton review MINOR #7: tools that need the cache should raise
consistently when it's missing — same shape as `_client()` for `_axl`.
Pre-fix, cache tools returned `{"error": "..."}` while AXL tools raised
RuntimeError; LLMs had to handle two patterns. Now: all tool failures
raise RuntimeError uniformly.
"""
if _cache is None:
raise RuntimeError("Cache not initialized — server bootstrap failed.")
return _cache
@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()
return _require_cache().stats()
@mcp.tool
def cache_clear(method_pattern: str | None = None) -> dict:
"""Clear cache entries.
"""Clear cache entries for the current cluster.
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)
deleted = _require_cache().clear(method_pattern)
return {"deleted_entries": deleted, "method_pattern": method_pattern}
@mcp.tool
def health() -> dict:
"""Server-state self-check: which globals are initialized?
Useful when a tool call returns "not initialized" surfaces which
subsystem (cache, AXL client, docs index) actually failed at bootstrap.
Also reports the AXL connection state from connection_status() so an
operator can see whether a recent operational error has cleared.
"""
info = {
"cache": _cache is not None,
"axl": _axl is not None,
"docs": _docs is not None,
"risport": _ris is not None,
}
if _axl is not None:
info["axl_connection"] = _axl.connection_status()
if _cache is not None:
try:
info["cache_cluster_id"] = _cache.cluster_id
except AttributeError:
pass
return info
# ====================================================================
# Route plan tools
# ====================================================================
@ -279,6 +305,69 @@ def route_devices_using_css(css_name: str, max_per_category: int = 50) -> dict:
return route_plan.find_devices_using_css(_client(), css_name, max_per_category)
@mcp.tool
def device_registration_status(
device_class: str = "Phone",
status: str = "Any",
name_filter: str | None = None,
page_size: int = 200,
) -> dict:
"""Real-time device registration status from CUCM RisPort70.
Complementary to AXL: AXL tells us what's *configured*; RisPort tells
us what's *currently registered*. The most audit-relevant cross-
reference is "configured but unregistered" (likely orphan).
Args:
device_class: One of "Phone" (default), "Gateway", "H323", "CTI",
"VoiceMail", "MediaResources", "HuntList", "SIPTrunk", "Any".
status: One of "Any" (default), "Registered", "UnRegistered",
"Rejected", "PartiallyRegistered", "Unknown".
name_filter: Optional name (or wildcard `*` substring) to narrow
the result. Maps to RisPort's SelectItems.
page_size: Max devices per RisPort call. RisPort caps at 1000.
"""
if _ris is None:
raise RuntimeError("RisPort client not initialized — server bootstrap failed.")
if name_filter:
return _ris.select_cm_device(
max_devices=page_size,
device_class=device_class,
status=status,
name_filter=name_filter,
)
return _ris.select_all(
device_class=device_class,
status=status,
page_size=page_size,
)
@mcp.tool
def device_registration_summary() -> dict:
"""High-level cluster registration health: counts by status across
all device classes that matter for audit work.
Useful as a once-per-conversation orientation: "are most phones
actually registered, or is something broken?"
"""
if _ris is None:
raise RuntimeError("RisPort client not initialized — server bootstrap failed.")
summary = {}
for cls in ("Phone", "Gateway", "H323", "SIPTrunk", "HuntList", "CTI"):
try:
r = _ris.select_all(device_class=cls, status="Any")
summary[cls] = {
"total_devices_found": r["total_devices_found"],
"devices_returned": r["devices_returned"],
"status_counts": r["status_counts"],
"cm_nodes_seen": r["cm_nodes_seen"],
}
except Exception as e:
summary[cls] = {"error": str(e)[:200]}
return summary
@mcp.tool
def route_filters(name: str | None = None, include_members: bool = False) -> dict:
"""List route filters with their composition rules.
@ -299,128 +388,29 @@ def route_filters(name: str | None = None, include_members: bool = False) -> dic
# ====================================================================
# Prompts — schema-grounded conversation seeds
#
# Bodies live in `mcaxl.prompts.<name>`. The shims below are the
# FastMCP registration surface; FastMCP introspects each shim's signature
# to expose parameters to the LLM, so the parameter contract lives here.
# Each shim is a thin pass-through to the corresponding render() function.
# ====================================================================
_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",
],
}
from . import prompts as _prompts
@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.
Use this when starting 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.
"""
return _prompts.route_plan_overview.render(_docs)
@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}
"""
return _prompts.investigate_pattern.render(_docs, pattern, partition)
@mcp.prompt
@ -431,101 +421,80 @@ def audit_routing(focus: str = "full") -> str:
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.
"""
return _prompts.audit_routing.render(_docs, focus)
@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 _prompts.cucm_sql_help.render(_docs, question)
return f"""# CUCM SQL Question
The user asks: **{question}**
@mcp.prompt
def sip_trunk_report(name_filter: str | None = None) -> str:
"""Comprehensive SIP trunk inventory: profiles, destinations, route-group
membership, and findings template.
## How to approach this
Args:
name_filter: Optional substring to narrow inventory to specific
trunks (case-sensitive LIKE). Omit to include all SIP trunks.
"""
return _prompts.sip_trunk_report.render(_docs, name_filter)
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
@mcp.prompt
def phone_inventory_report(filter: str | None = None) -> str:
"""Phone fleet audit: counts by model/pool/CSS, anomaly findings,
orphan-owner cross-check.
{schema_block}
Args:
filter: Optional substring (matches device name OR description) to
narrow the inventory. Omit to include all phones.
"""
return _prompts.phone_inventory_report.render(_docs, filter)
Now answer the question.
"""
@mcp.prompt
def user_audit(focus: str = "full") -> str:
"""End user + application user audit: roles, group memberships,
security-relevant findings.
Args:
focus: One of "full", "admin", "inactive", "app_users". Tunes
which checklist sections the report emphasizes; all queries
still run for context.
"""
return _prompts.user_audit.render(_docs, focus)
@mcp.prompt
def inbound_did_audit() -> str:
"""Inbound DID inventory: XFORM-Inbound-DNIS curated list, executed
routing in Internal-PT, spam blocklist, orphan-target cross-check."""
return _prompts.inbound_did_audit.render(_docs)
@mcp.prompt
def hunt_pilot_audit() -> str:
"""Hunt pilot audit: queue settings, distribution algorithms, line
group membership, dead-pilot detection. Schema-aware (uses
huntpilotqueue.fknumplan_pilot, the verified column name)."""
return _prompts.hunt_pilot_audit.render(_docs)
@mcp.prompt
def whoami(userid: str | None = None) -> str:
"""Look up the role chain for a single user (defaults to the AXL
service account). Surfaces access-control-group membership, attached
function roles, and findings around misnamed groups or excess
privileges.
Args:
userid: User identity to look up. If omitted, defaults to the
value of the AXL_USER environment variable (the account this
MCP server uses to talk to the cluster).
"""
return _prompts.whoami.render(_docs, userid)
# ====================================================================
@ -534,16 +503,16 @@ Now answer the question.
def _banner() -> None:
try:
v = _pkg_version("mcp-cucm-axl")
v = _pkg_version("mcaxl")
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)
print(f"[mcaxl] v{v} starting", file=sys.stderr, flush=True)
print(f"[mcaxl] AXL_URL={axl_url}", file=sys.stderr, flush=True)
def main() -> None:
global _cache, _axl, _docs
global _cache, _axl, _docs, _ris
# Load .env from the project directory (where the user runs uv from)
cwd_env = Path.cwd() / ".env"
@ -554,19 +523,31 @@ def main() -> None:
cache_dir = Path(
os.environ.get("AXL_CACHE_DIR")
or (Path(user_cache_dir("mcp-cucm-axl")) / "responses")
or (Path(user_cache_dir("mcaxl")) / "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)
# Cluster-id derived from AXL_URL. Hash keeps the key compact and
# avoids leaking the URL into log output where the cache key gets
# printed. Hostname-only fallback when AXL_URL is unset (test mode).
import hashlib
axl_url_for_id = os.environ.get("AXL_URL", "no-axl-url-configured")
cluster_id = hashlib.sha256(axl_url_for_id.encode()).hexdigest()[:12]
_cache = AxlCache(
cache_dir / "axl_responses.sqlite",
default_ttl=ttl,
cluster_id=cluster_id,
)
print(
f"[mcp-cucm-axl] cache: {_cache.db_path} (ttl={ttl}s)",
f"[mcaxl] cache: {_cache.db_path} "
f"(ttl={ttl}s, cluster_id={cluster_id})",
file=sys.stderr,
flush=True,
)
_axl = AxlClient(_cache)
_docs = DocsIndex.load() # may be None; prompts handle gracefully
_ris = RisPortClient()
mcp.run()

View File

@ -14,6 +14,8 @@ import re
_COMMENT_BLOCK = re.compile(r"/\*.*?\*/", re.DOTALL)
_COMMENT_LINE = re.compile(r"--[^\n]*")
# Match Informix string literals: single-quoted, with '' as escaped quote.
_STRING_LITERAL = re.compile(r"'(?:''|[^'])*'", re.DOTALL)
_FORBIDDEN = {
"INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER",
"TRUNCATE", "GRANT", "REVOKE", "MERGE", "REPLACE", "RENAME",
@ -31,17 +33,38 @@ def validate_select(query: str) -> str:
Accepts SELECT and WITH (CTEs that ultimately return SELECT). Rejects
anything else, and any query containing forbidden keywords as standalone
tokens.
tokens *outside* string literals and comments.
Hamilton review CRITICAL #1: the output we return MUST preserve the input
byte-for-byte (modulo trailing semicolon and outer whitespace). Earlier
versions ran a non-literal-aware comment strip on the output, which would
silently eat `--` and `/* */` markers that legitimately appeared inside
string literals like `WHERE description = 'Smith -- old line'`. The query
going to AXL must be exactly what the caller intended comment stripping
is an analysis-only operation, never a mutation of the wire query.
"""
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:
# The query we'll send to AXL: original input, with only outer whitespace
# and a single trailing semicolon trimmed. NO mutation of literals or
# in-string comment markers.
cleaned = query.strip().rstrip(";").strip()
if not cleaned:
raise SqlValidationError("Query is empty after trimming.")
# Analysis-only copy: strip string literals AND comments (in either order
# is safe here, since each strip uses its own regex on a non-AXL-bound
# buffer). Order chosen: literals first, then comments, so that any
# comment markers genuinely outside literals can be detected.
for_analysis = _STRING_LITERAL.sub(" ", cleaned)
for_analysis = _COMMENT_BLOCK.sub(" ", for_analysis)
for_analysis = _COMMENT_LINE.sub(" ", for_analysis)
if not for_analysis.strip():
raise SqlValidationError("Query is empty after stripping comments.")
upper_tokens = [t.upper() for t in _WORD_RE.findall(stripped)]
upper_tokens = [t.upper() for t in _WORD_RE.findall(for_analysis)]
if not upper_tokens:
raise SqlValidationError("Query contains no SQL keywords.")
@ -58,4 +81,4 @@ def validate_select(query: str) -> str:
f"This server is read-only."
)
return stripped
return cleaned

View File

@ -2,7 +2,7 @@
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
2. ~/.cache/mcaxl/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
@ -28,8 +28,8 @@ 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
"""Return ~/.cache/mcaxl/wsdl/<version>/."""
return Path(user_cache_dir("mcaxl")) / "wsdl" / version
def _has_complete_wsdl(directory: Path) -> bool:
@ -59,7 +59,7 @@ def _try_extract_from_zip(version: str = WSDL_VERSION) -> Path | None:
target.mkdir(parents=True, exist_ok=True)
print(
f"[mcp-cucm-axl] extracting AXL schema {version} from {zip_path}",
f"[mcaxl] extracting AXL schema {version} from {zip_path}",
file=sys.stderr,
flush=True,
)
@ -71,7 +71,7 @@ def _try_extract_from_zip(version: str = WSDL_VERSION) -> Path | None:
member = f"schema/{version}/{fname}"
if member not in zf.namelist():
print(
f"[mcp-cucm-axl] zip missing member: {member}",
f"[mcaxl] zip missing member: {member}",
file=sys.stderr,
flush=True,
)
@ -80,13 +80,13 @@ def _try_extract_from_zip(version: str = WSDL_VERSION) -> Path | None:
(target / fname).write_bytes(data)
extracted.append(fname)
print(
f"[mcp-cucm-axl] extracted {len(extracted)} files into {target}",
f"[mcaxl] 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)
print(f"[mcaxl] zip extraction failed: {e}", file=sys.stderr, flush=True)
return None
@ -106,7 +106,7 @@ def resolve_wsdl_path() -> Path:
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"[mcaxl] warning: WSDL dir missing {missing} alongside {p.name}; "
f"zeep may fail to resolve schema imports.",
file=sys.stderr,
flush=True,

View File

@ -1,5 +0,0 @@
"""mcp-cucm-axl — read-only MCP server for CUCM 15 AXL."""
from .server import main
__all__ = ["main"]

View File

@ -1,129 +0,0 @@
"""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

View File

@ -5,7 +5,7 @@ from pathlib import Path
import pytest
from mcp_cucm_axl.cache import AxlCache
from mcaxl.cache import AxlCache
@pytest.fixture
@ -85,3 +85,95 @@ def test_purge_expired(tmp_path: Path):
purged = c.purge_expired()
assert purged == 1
assert c.stats()["live_entries"] == 1
class TestClusterIsolation:
"""Hamilton review CRITICAL #2: cache key omitted cluster identity.
Prior to the fix, `AXL_URL` swap (test prod, or one cluster to another)
served stale results from cluster A as if from cluster B. The cache
couldn't tell the data came from a different mission. Now each cache
handle is bound to a cluster_id, and entries from a different cluster
must miss.
"""
def test_different_cluster_ids_isolate_get(self, tmp_path: Path):
# Both caches point at the same DB file, but bound to different
# cluster IDs. A's writes must not be visible to B.
db = tmp_path / "shared.sqlite"
a = AxlCache(db, default_ttl=60, cluster_id="cluster-A")
b = AxlCache(db, default_ttl=60, cluster_id="cluster-B")
a.set("getCCMVersion", {}, {"version": "12.5"})
assert a.get("getCCMVersion", {}) == {"version": "12.5"}
assert b.get("getCCMVersion", {}) is None, (
"cluster-B must not see cluster-A's cached value"
)
def test_same_cluster_id_shares_cache(self, tmp_path: Path):
# Two handles with the SAME cluster_id should share results.
db = tmp_path / "shared.sqlite"
a = AxlCache(db, default_ttl=60, cluster_id="cluster-X")
a.set("listPhone", {"name": "SEP1"}, {"rows": ["one"]})
b = AxlCache(db, default_ttl=60, cluster_id="cluster-X")
assert b.get("listPhone", {"name": "SEP1"}) == {"rows": ["one"]}
def test_cluster_id_in_stats(self, tmp_path: Path):
c = AxlCache(tmp_path / "s.sqlite", default_ttl=60, cluster_id="cluster-Y")
c.set("getCCMVersion", {}, {"v": "15"})
stats = c.stats()
assert stats.get("cluster_id") == "cluster-Y", (
"stats must surface cluster_id so operators can verify which cluster they're caching"
)
def test_no_cluster_id_still_works_legacy(self, tmp_path: Path):
# Backward compat: no cluster_id keeps the old (but now risky) shape.
# The cache still functions; we just don't get isolation.
c = AxlCache(tmp_path / "legacy.sqlite", default_ttl=60)
c.set("x", {}, "y")
assert c.get("x", {}) == "y"
def test_clear_only_affects_current_cluster(self, tmp_path: Path):
db = tmp_path / "shared.sqlite"
a = AxlCache(db, default_ttl=60, cluster_id="cluster-A")
b = AxlCache(db, default_ttl=60, cluster_id="cluster-B")
a.set("x", {}, "from-A")
b.set("x", {}, "from-B")
deleted = a.clear()
assert deleted == 1, "clear() must only affect this cluster's entries"
assert b.get("x", {}) == "from-B", "cluster-B's entry must survive A's clear"
def test_migrate_legacy_database(self, tmp_path: Path):
"""A cache database created before the cluster_id fix must
upgrade transparently no `no such column` error on next INSERT.
"""
import sqlite3
db = tmp_path / "legacy.sqlite"
# Manually create the OLD schema (no cluster_id column)
conn = sqlite3.connect(db)
conn.executescript(
"""
CREATE TABLE 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
);
INSERT INTO axl_cache VALUES
('legacy-key', 'oldMethod', '{}', '"old-value"', 0, 9999999999);
"""
)
conn.commit()
conn.close()
# Open with the new code — must not raise, must add the column
c = AxlCache(db, default_ttl=60, cluster_id="new-cluster")
# The new client should NOT see the legacy entry (it has no cluster_id)
# — this is the cautious behavior; legacy entries are isolated to the
# "unknown cluster" bucket.
assert c.get("oldMethod", {}) is None
# And it must be able to write/read its own entries
c.set("newMethod", {"a": 1}, "new-value")
assert c.get("newMethod", {"a": 1}) == "new-value"

View File

@ -0,0 +1,155 @@
"""Hamilton review MAJOR #5: connection recovery and config-vs-operational errors.
Pre-fix: any connection failure set `_connection_error` and pinned it forever.
A transient network blip required restarting the MCP server. Fix: distinguish
*configuration* errors (missing env, bad WSDL) which are pinned, from
*operational* errors (network, TLS, session timeout) which can be retried
on the next call.
"""
from pathlib import Path
import pytest
from mcaxl.cache import AxlCache
from mcaxl.client import AxlClient
@pytest.fixture
def cache(tmp_path: Path) -> AxlCache:
return AxlCache(tmp_path / "test.sqlite", default_ttl=60, cluster_id="test")
def test_config_error_is_pinned(cache: AxlCache, monkeypatch):
"""Missing AXL_URL is a config error — it doesn't get better on retry,
and the next call should still raise the same clear message."""
monkeypatch.delenv("AXL_URL", raising=False)
monkeypatch.delenv("AXL_USER", raising=False)
monkeypatch.delenv("AXL_PASS", raising=False)
client = AxlClient(cache)
with pytest.raises(RuntimeError, match="AXL_URL"):
client._ensure_connected()
# Second call: same config error, pinned
with pytest.raises(RuntimeError, match="AXL_URL"):
client._ensure_connected()
def test_operational_error_is_not_pinned(cache: AxlCache, monkeypatch):
"""A transient operational error (zeep Client construction failing,
network blip, etc.) should NOT pin the client forever. The next call
must be allowed to retry."""
monkeypatch.setenv("AXL_URL", "https://test.invalid:8443/axl")
monkeypatch.setenv("AXL_USER", "test")
monkeypatch.setenv("AXL_PASS", "test")
monkeypatch.setenv("AXL_VERIFY_TLS", "false")
# Force the zeep Client constructor inside _ensure_connected to raise.
# This simulates "WSDL fetch failed", "TLS handshake error", etc. —
# transient operational failures.
from mcaxl import client as client_mod
def boom(*args, **kwargs):
raise ConnectionError("simulated transient network failure")
monkeypatch.setattr(client_mod, "Client", boom)
client = AxlClient(cache)
with pytest.raises(RuntimeError, match="simulated transient"):
client._ensure_connected()
# Hamilton review MAJOR #5: operational errors must NOT set _config_error.
# _config_error is the permanent pin; only set on missing env vars / config
# mistakes. A failed network connection is operational and the next call
# must be allowed to retry.
assert client._config_error is None, (
"operational errors must not set _config_error (the pin); "
"only configuration errors (missing env vars, bad WSDL) should pin"
)
# _last_error is set for diagnostics, but it does not block retries.
assert client._last_error is not None, (
"_last_error should record the operational failure for diagnostics"
)
assert "simulated transient" in client._last_error
def test_health_diagnostic_includes_connection_state(cache: AxlCache):
"""The client should expose its connection age / last-attempt info
so an operator can see what's going on without reading sys.stderr."""
client = AxlClient(cache)
info = client.connection_status()
assert "connected" in info
assert info["connected"] is False # never tried yet
assert "last_error" in info
# ---- Rate limit / 503 retry --------------------------------------------------
# Inspired by cisco-cucm-mcp's exponential-backoff approach. CUCM's SOAP
# layer returns 503 under load (concurrent AXL admins, change window). Without
# retries, we'd fail loudly; with them, transient rate limiting becomes
# invisible to the caller.
def test_retry_config_default_three_retries(cache: AxlCache, monkeypatch):
"""By default, the session is configured for 3 retries with backoff."""
monkeypatch.setenv("AXL_URL", "https://example.invalid:8443/axl")
monkeypatch.setenv("AXL_USER", "test")
monkeypatch.setenv("AXL_PASS", "test")
monkeypatch.setenv("AXL_VERIFY_TLS", "false")
# Stub Client construction so we exercise only the session/retry setup
from mcaxl import client as client_mod
constructed = {}
def stub_client(*args, **kwargs):
constructed["transport"] = kwargs.get("transport")
# Raise to short-circuit before service creation
raise ConnectionError("stub: don't actually connect")
monkeypatch.setattr(client_mod, "Client", stub_client)
client = AxlClient(cache)
with pytest.raises(RuntimeError):
client._ensure_connected()
info = client.connection_status()
assert info["retry_config"] is not None
assert info["retry_config"]["max_retries"] == 3
assert 503 in info["retry_config"]["status_forcelist"]
assert 502 in info["retry_config"]["status_forcelist"]
assert 504 in info["retry_config"]["status_forcelist"]
def test_retry_config_overridable_via_env(cache: AxlCache, monkeypatch):
"""Operators can tune the retry count via AXL_RATE_LIMIT_RETRIES."""
monkeypatch.setenv("AXL_URL", "https://example.invalid:8443/axl")
monkeypatch.setenv("AXL_USER", "test")
monkeypatch.setenv("AXL_PASS", "test")
monkeypatch.setenv("AXL_RATE_LIMIT_RETRIES", "7")
from mcaxl import client as client_mod
monkeypatch.setattr(client_mod, "Client", lambda *a, **kw: (_ for _ in ()).throw(ConnectionError("stub")))
client = AxlClient(cache)
with pytest.raises(RuntimeError):
client._ensure_connected()
assert client.connection_status()["retry_config"]["max_retries"] == 7
def test_retry_config_zero_disables(cache: AxlCache, monkeypatch):
"""AXL_RATE_LIMIT_RETRIES=0 disables the retry adapter entirely.
Useful for test environments or when an operator wants raw failures."""
monkeypatch.setenv("AXL_URL", "https://example.invalid:8443/axl")
monkeypatch.setenv("AXL_USER", "test")
monkeypatch.setenv("AXL_PASS", "test")
monkeypatch.setenv("AXL_RATE_LIMIT_RETRIES", "0")
from mcaxl import client as client_mod
monkeypatch.setattr(client_mod, "Client", lambda *a, **kw: (_ for _ in ()).throw(ConnectionError("stub")))
client = AxlClient(cache)
with pytest.raises(RuntimeError):
client._ensure_connected()
cfg = client.connection_status()["retry_config"]
assert cfg["max_retries"] == 0

319
tests/test_css_impact.py Normal file
View File

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

View File

@ -5,7 +5,7 @@ from pathlib import Path
import pytest
from mcp_cucm_axl.docs_loader import DocsIndex
from mcaxl.docs_loader import DocsIndex
@pytest.fixture

View File

@ -2,7 +2,7 @@
import pytest
from mcp_cucm_axl.normalize import (
from mcaxl.normalize import (
BOOLEAN_COLUMNS,
normalize_bool,
normalize_row,

View File

@ -0,0 +1,291 @@
"""Tests for the extracted prompts package.
The render() functions are pure (they take docs as a parameter, no module
globals), so they're trivially unit-testable. We verify each one renders
without raising both with and without a docs index.
"""
import json
from pathlib import Path
import pytest
from mcaxl import prompts
from mcaxl.docs_loader import DocsIndex
@pytest.fixture
def fake_docs(tmp_path: Path) -> DocsIndex:
chunks = [
{
"id": "cucm::v15::admin::Route-Plan::0",
"text": "Route plan defines call routing.",
"heading_path": ["Route Plan"],
"source_path": str(tmp_path / "fake.md"),
"product": "cucm",
"version": "v15",
"doc": "system-config-guide",
},
{
"id": "cucm::v15::admin::SIP-Trunk::0",
"text": "SIP trunks connect CUCM to other call control entities.",
"heading_path": ["SIP Trunk Configuration"],
"source_path": str(tmp_path / "fake.md"),
"product": "cucm",
"version": "v15",
"doc": "system-config-guide",
},
]
(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"]})
)
idx = DocsIndex.load(tmp_path)
assert idx is not None
return idx
# ---- route_plan_overview ----------------------------------------------------
def test_route_plan_overview_renders_with_docs(fake_docs):
text = prompts.route_plan_overview.render(fake_docs)
assert "Route Plan Overview" in text
assert "axl_version" in text
assert "route_partitions" in text
def test_route_plan_overview_renders_without_docs():
# Graceful degradation — no docs index should still produce a usable prompt
text = prompts.route_plan_overview.render(None)
assert "Route Plan Overview" in text
assert "cisco-docs index is not loaded" in text
# ---- investigate_pattern ---------------------------------------------------
def test_investigate_pattern_includes_pattern_in_output(fake_docs):
text = prompts.investigate_pattern.render(fake_docs, "10.911", "CER911-PT")
assert "10.911" in text
assert "CER911-PT" in text
def test_investigate_pattern_no_partition(fake_docs):
text = prompts.investigate_pattern.render(fake_docs, "9.@")
assert "9.@" in text
# The partition clause should not appear when no partition is given
assert "in partition" not in text
# ---- audit_routing ----------------------------------------------------------
def test_audit_routing_default_focus(fake_docs):
text = prompts.audit_routing.render(fake_docs)
assert "focus: `full`" in text
def test_audit_routing_custom_focus(fake_docs):
text = prompts.audit_routing.render(fake_docs, "translations")
assert "focus: `translations`" in text
# ---- cucm_sql_help ----------------------------------------------------------
def test_cucm_sql_help_includes_question(fake_docs):
q = "How do I find phones with no associated user?"
text = prompts.cucm_sql_help.render(fake_docs, q)
assert q in text
assert "axl_describe_table" in text
def test_cucm_sql_help_handles_empty_question(fake_docs):
# No substantive keywords — must still render something useful
text = prompts.cucm_sql_help.render(fake_docs, "x y z")
assert "axl_list_tables" in text
# ---- sip_trunk_report (NEW) ------------------------------------------------
def test_sip_trunk_report_includes_query_1(fake_docs):
text = prompts.sip_trunk_report.render(fake_docs)
# Query 1 essentials: device JOIN sipdevice, the trunk_name column,
# and the WHERE filter on Trunk class
assert "FROM device d" in text
assert "JOIN sipdevice sd" in text
assert "tc.name = 'Trunk'" in text
def test_sip_trunk_report_includes_query_2_and_tools(fake_docs):
text = prompts.sip_trunk_report.render(fake_docs)
assert "siptrunkdestination" in text
# And references the existing MCP tool for route group traversal
assert "route_lists_and_groups()" in text
def test_sip_trunk_report_with_name_filter_injects_safe_like(fake_docs):
text = prompts.sip_trunk_report.render(fake_docs, "PSTN")
# The filter should appear as a LIKE clause and the scope note should
# reflect the narrowing
assert "%PSTN%" in text
assert "narrowed to trunks" in text
def test_sip_trunk_report_name_filter_escaped(fake_docs):
# SQL injection defense: a single-quote in the name_filter must be
# doubled before going into the LIKE pattern
text = prompts.sip_trunk_report.render(fake_docs, "O'Reilly")
assert "O''Reilly" in text
def test_sip_trunk_report_renders_without_docs():
text = prompts.sip_trunk_report.render(None)
assert "SIP Trunk Report" in text
assert "cisco-docs index is not loaded" in text
# ---- registration smoke test -----------------------------------------------
def test_all_prompts_registered_in_server():
"""Confirm each prompt module's render() is wired through a FastMCP shim
in server.py. Catches the case where a new module is added but the
shim wasn't (the prompt would be invisible to the LLM)."""
import asyncio
from mcaxl import server
async def _list():
registered = await server.mcp.list_prompts()
return {p.name for p in registered}
names = asyncio.run(_list())
assert names == {
"route_plan_overview",
"investigate_pattern",
"audit_routing",
"cucm_sql_help",
"sip_trunk_report",
"phone_inventory_report",
"user_audit",
"inbound_did_audit",
"hunt_pilot_audit",
"whoami",
}, f"unexpected prompt set: {names}"
# ---- new prompts: render-with-and-without-docs smoke tests -----------------
def test_phone_inventory_report_renders(fake_docs):
text = prompts.phone_inventory_report.render(fake_docs)
assert "Phone Inventory" in text
# Embedded SQL should query the `device` table with tkclass='Phone'
assert "tc.name = 'Phone'" in text
assert "all phones" in text
def test_phone_inventory_report_with_filter(fake_docs):
text = prompts.phone_inventory_report.render(fake_docs, "Lobby")
assert "%Lobby%" in text
assert "narrowed" in text
def test_phone_inventory_report_filter_escaped(fake_docs):
text = prompts.phone_inventory_report.render(fake_docs, "O'Hara")
assert "O''Hara" in text # SQL injection defense
def test_user_audit_default_focus(fake_docs):
text = prompts.user_audit.render(fake_docs)
assert "focus: `full`" in text
assert "applicationuser" in text
assert "enduser" in text
def test_user_audit_admin_focus(fake_docs):
text = prompts.user_audit.render(fake_docs, "admin")
assert "focus: `admin`" in text
def test_user_audit_unknown_focus_falls_back_to_full(fake_docs):
text = prompts.user_audit.render(fake_docs, "bogus_value")
assert "focus: `full`" in text
def test_inbound_did_audit_renders(fake_docs):
text = prompts.inbound_did_audit.render(fake_docs)
assert "Inbound DID Audit" in text
assert "XFORM-Inbound-DNIS" in text
assert "PSTN-Screen-PT" in text
def test_hunt_pilot_audit_uses_correct_column(fake_docs):
"""Hamilton bonus finding: huntpilotqueue joins via fknumplan_pilot,
NOT fknumplan. The prompt's embedded SQL must use the correct column
or every audit run will silently fail one category."""
text = prompts.hunt_pilot_audit.render(fake_docs)
assert "fknumplan_pilot" in text, (
"hunt_pilot_audit must use the verified column name `fknumplan_pilot`; "
"`fknumplan` was the silent-failure version we fixed in Hamilton review"
)
assert "tkpatternusage = 7" in text # Hunt Pilot type code
def test_all_new_prompts_render_without_docs():
"""Graceful degradation: each prompt produces usable output even when
the docs index isn't loaded."""
for name, fn, args in [
("phone_inventory_report", prompts.phone_inventory_report.render, ()),
("user_audit", prompts.user_audit.render, ()),
("inbound_did_audit", prompts.inbound_did_audit.render, ()),
("hunt_pilot_audit", prompts.hunt_pilot_audit.render, ()),
("whoami", prompts.whoami.render, ()),
]:
text = fn(None, *args)
assert "cisco-docs index is not loaded" in text, (
f"{name} failed graceful degradation"
)
# ---- whoami specifics ------------------------------------------------------
def test_whoami_uses_correct_table_names_for_cucm_15(fake_docs):
"""The query principle came in with `enduserauthgroupmap` and
`dirgrouprolemap`, but those tables don't exist on CUCM 15. The
prompt MUST embed the verified names: `enduserdirgroupmap` and
`functionroledirgroupmap`. If a future contributor reverts to the
older names, this test fires red."""
text = prompts.whoami.render(fake_docs, "TestUser")
assert "enduserdirgroupmap" in text
assert "functionroledirgroupmap" in text
# And the older deprecated names should NOT appear in the SQL we ship
# (they're fine in the prose section that explains why the names changed)
sql_section = text.split("## Schema knowledge")[0] + text.split("## Step")[1]
for old_name in ("enduserauthgroupmap", "dirgrouprolemap"):
assert old_name not in sql_section, (
f"deprecated table name {old_name} appeared in the SQL section"
)
def test_whoami_explicit_userid_in_query(fake_docs):
text = prompts.whoami.render(fake_docs, "SomeAccount")
assert "SomeAccount" in text
assert "explicit userid" in text
def test_whoami_default_uses_axl_user_env(fake_docs, monkeypatch):
monkeypatch.setenv("AXL_USER", "EnvAccount")
text = prompts.whoami.render(fake_docs)
assert "EnvAccount" in text
assert "AXL service account" in text
def test_whoami_no_userid_no_env(fake_docs, monkeypatch):
monkeypatch.delenv("AXL_USER", raising=False)
text = prompts.whoami.render(fake_docs)
assert "no userid supplied" in text
# Tells the LLM what to do — set the param or the env var
assert "set userid parameter" in text or "AXL_USER env var" in text
def test_whoami_userid_escaped_for_sql_safety(fake_docs):
text = prompts.whoami.render(fake_docs, "O'Brien")
assert "O''Brien" in text # single quote doubled per Informix convention

222
tests/test_risport.py Normal file
View File

@ -0,0 +1,222 @@
"""Unit tests for the RisPort70 SOAP envelope construction and parser.
Live-cluster integration is verified separately via the smoke-test
script these tests are pure: envelope shape, response-XML parsing,
edge cases. No network.
"""
import xml.etree.ElementTree as ET
import pytest
from mcaxl.risport import (
DEVICE_STATUS_VALUES,
RisPortClient,
_build_select_envelope,
_escape_xml,
_parse_response,
)
class TestEscapeXml:
def test_basic_escapes(self):
assert _escape_xml("<bad>") == "&lt;bad&gt;"
assert _escape_xml("a&b") == "a&amp;b"
assert _escape_xml('"x"') == "&quot;x&quot;"
assert _escape_xml("'y'") == "&apos;y&apos;"
def test_passthrough_safe_text(self):
assert _escape_xml("Phone-1234") == "Phone-1234"
class TestSelectEnvelope:
def test_envelope_is_well_formed_xml(self):
env = _build_select_envelope()
# Must parse — if not, RisPort will reject it
root = ET.fromstring(env)
assert root is not None
def test_default_envelope_includes_required_fields(self):
env = _build_select_envelope()
# Cisco's WSDL requires every CmSelectionCriteria child
for required in [
"MaxReturnedDevices",
"DeviceClass",
"Model",
"Status",
"NodeName",
"SelectBy",
"SelectItems",
"Protocol",
"DownloadStatus",
]:
assert f"<soap:{required}>" in env, (
f"missing required CmSelectionCriteria field <soap:{required}>"
)
def test_state_info_threaded_into_envelope(self):
env = _build_select_envelope(state_info="cursor-abc-123")
assert "cursor-abc-123" in env
assert "<soap:StateInfo>cursor-abc-123</soap:StateInfo>" in env
def test_select_items_default_wildcard(self):
env = _build_select_envelope()
# Default: ['*'] — all devices
assert "<soap:Item>*</soap:Item>" in env
def test_select_items_explicit_list(self):
env = _build_select_envelope(select_items=["SEP1", "SEP2"])
assert "<soap:Item>SEP1</soap:Item>" in env
assert "<soap:Item>SEP2</soap:Item>" in env
def test_max_devices_int_coerced(self):
env = _build_select_envelope(max_devices=500)
assert "<soap:MaxReturnedDevices>500</soap:MaxReturnedDevices>" in env
def test_xml_special_chars_in_filter_escaped(self):
# SOAP injection defense — single quote and angle bracket must be escaped
env = _build_select_envelope(select_items=["O'Brien <test>"])
assert "&apos;" in env
assert "&lt;" in env
assert "&gt;" in env
# The raw chars must NOT appear
assert "'Brien <test>" not in env
class TestParseResponse:
# Match CUCM 15's actual response shape: an extra <SelectCmDeviceResult>
# wrapper inside <selectCmDeviceReturn>. Discovered while live-verifying
# against cucm-pub.binghammemorial.org 2026-04-26 — the parser must
# descend through this wrapper.
SAMPLE_RESPONSE = """<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<ns:selectCmDeviceResponse xmlns:ns="http://schemas.cisco.com/ast/soap">
<selectCmDeviceReturn>
<SelectCmDeviceResult>
<TotalDevicesFound>2</TotalDevicesFound>
<StateInfo></StateInfo>
<CmNodes>
<item>
<Name>cucm-pub</Name>
<ReturnCode>Ok</ReturnCode>
<CmDevices>
<item>
<Name>SEP1234567890AB</Name>
<IpAddress><item><IP>10.0.0.5</IP><IPAddrType>ipv4</IPAddrType></item></IpAddress>
<DirNumber>2001</DirNumber>
<Status>Registered</Status>
<StatusReason>0</StatusReason>
<Protocol>SIP</Protocol>
<Description>Patient Room 101</Description>
<Model>Cisco 7841</Model>
<ActiveLoadID>sip78xx.12-5-1SR4-3</ActiveLoadID>
<TimeStamp>1700000000</TimeStamp>
</item>
<item>
<Name>SEPABCDEF123456</Name>
<IpAddress></IpAddress>
<DirNumber></DirNumber>
<Status>UnRegistered</Status>
<StatusReason>1</StatusReason>
<Description>Decommissioned phone</Description>
</item>
</CmDevices>
</item>
</CmNodes>
</SelectCmDeviceResult>
</selectCmDeviceReturn>
</ns:selectCmDeviceResponse>
</soapenv:Body>
</soapenv:Envelope>"""
# Pre-CUCM-15 shape (no SelectCmDeviceResult wrapper). The parser falls
# back to fields directly under selectCmDeviceReturn for backward compat.
LEGACY_RESPONSE = """<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<ns:selectCmDeviceResponse xmlns:ns="http://schemas.cisco.com/ast/soap">
<selectCmDeviceReturn>
<TotalDevicesFound>1</TotalDevicesFound>
<StateInfo></StateInfo>
<CmNodes>
<item>
<Name>cucm-old</Name>
<ReturnCode>Ok</ReturnCode>
<CmDevices>
<item>
<Name>SEPLEGACY</Name>
<Status>Registered</Status>
</item>
</CmDevices>
</item>
</CmNodes>
</selectCmDeviceReturn>
</ns:selectCmDeviceResponse>
</soapenv:Body>
</soapenv:Envelope>"""
def test_legacy_response_shape_still_parses(self):
"""Backward compat with pre-CUCM-15 RisPort responses."""
result = _parse_response(self.LEGACY_RESPONSE)
assert result["total_devices_found"] == 1
assert result["cm_nodes"][0]["devices"][0]["name"] == "SEPLEGACY"
def test_parse_total_count(self):
result = _parse_response(self.SAMPLE_RESPONSE)
assert result["total_devices_found"] == 2
def test_parse_node_count(self):
result = _parse_response(self.SAMPLE_RESPONSE)
assert len(result["cm_nodes"]) == 1
assert result["cm_nodes"][0]["name"] == "cucm-pub"
def test_parse_device_fields(self):
result = _parse_response(self.SAMPLE_RESPONSE)
devices = result["cm_nodes"][0]["devices"]
assert len(devices) == 2
first = devices[0]
assert first["name"] == "SEP1234567890AB"
assert first["status"] == "Registered"
assert first["dir_number"] == "2001"
assert first["description"] == "Patient Room 101"
assert first["protocol"] == "SIP"
# Nested IP extraction
assert first["ip_address"] == "10.0.0.5"
def test_parse_unregistered_device(self):
result = _parse_response(self.SAMPLE_RESPONSE)
second = result["cm_nodes"][0]["devices"][1]
assert second["status"] == "UnRegistered"
assert second["ip_address"] == "" # missing IP renders empty
def test_parse_state_info_for_pagination(self):
result = _parse_response(self.SAMPLE_RESPONSE)
assert result["state_info"] == "" # last page
def test_parse_soap_fault_raises(self):
fault_response = """<?xml version="1.0"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Body>
<soapenv:Fault>
<faultstring>Authentication failed</faultstring>
</soapenv:Fault>
</soapenv:Body>
</soapenv:Envelope>"""
with pytest.raises(RuntimeError, match="Authentication failed"):
_parse_response(fault_response)
class TestStatusValidation:
def test_known_status_values(self):
assert "Registered" in DEVICE_STATUS_VALUES
assert "UnRegistered" in DEVICE_STATUS_VALUES
assert "PartiallyRegistered" in DEVICE_STATUS_VALUES
def test_select_cm_device_rejects_invalid_status(self):
client = RisPortClient()
# No env vars set, so _ensure_session would fail first;
# but the validation should run BEFORE that on bad input.
with pytest.raises(ValueError, match="status must be"):
client.select_cm_device(status="not-a-real-status")

View File

@ -0,0 +1,40 @@
"""Hamilton review MINOR #7: standardize tool-failure error shapes.
Pre-fix: tools that need state (`_axl`, `_cache`, `_docs`) had inconsistent
error shapes. Most tools called `_client()` which raises `RuntimeError`.
But `cache_stats` and `cache_clear` checked `if _cache is None` and
returned `{"error": "..."}`. An LLM consuming responses had to handle
two different patterns. After the fix, both shapes converge: all tools
raise RuntimeError when their dependencies aren't initialized.
"""
import pytest
from mcaxl import server
def test_cache_stats_raises_when_uninitialized(monkeypatch):
monkeypatch.setattr(server, "_cache", None)
with pytest.raises(RuntimeError, match=r"[Cc]ache"):
# @mcp.tool passes the function through unchanged; call directly.
server.cache_stats()
def test_cache_clear_raises_when_uninitialized(monkeypatch):
monkeypatch.setattr(server, "_cache", None)
with pytest.raises(RuntimeError, match=r"[Cc]ache"):
server.cache_clear()
def test_health_check_reports_each_subsystem(monkeypatch):
"""A health-check tool should report which globals are initialized,
so an operator (or an LLM) can diagnose `RuntimeError: ... not initialized`
issues without grepping source."""
# When all are None, health should report all three as down
monkeypatch.setattr(server, "_cache", None)
monkeypatch.setattr(server, "_axl", None)
monkeypatch.setattr(server, "_docs", None)
info = server.health()
assert info["cache"] is False
assert info["axl"] is False
assert info["docs"] is False

View File

@ -2,7 +2,7 @@
import pytest
from mcp_cucm_axl.sql_validator import validate_select, SqlValidationError
from mcaxl.sql_validator import validate_select, SqlValidationError
class TestSelectAccepted:
@ -82,3 +82,96 @@ class TestEdgeCases:
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)
class TestStringLiterals:
"""Forbidden keywords inside string literals must be ignored.
Otherwise CSS names like 'Call Forward-CSS', DN descriptions containing
'DELETE' (e.g., 'Delete this voicemail line'), or partition names with
'INSERT' would all fail to query, even though the SQL itself is read-only.
"""
def test_call_inside_string_literal_passes(self):
q = "SELECT pkid FROM callingsearchspace WHERE name = 'Call Forward-CSS'"
result = validate_select(q)
assert "Call Forward-CSS" in result # literal preserved
def test_delete_inside_string_literal_passes(self):
q = "SELECT pkid FROM numplan WHERE description = 'Delete after audit'"
result = validate_select(q)
assert "Delete after audit" in result
def test_drop_inside_string_literal_passes(self):
q = "SELECT pkid FROM numplan WHERE description = 'DROP table backup'"
assert validate_select(q)
def test_actual_drop_outside_literal_still_blocked(self):
with pytest.raises(SqlValidationError, match="DROP"):
validate_select("SELECT 1 FROM device; DROP TABLE backup")
def test_escaped_quote_in_literal(self):
# Informix uses '' (doubled) as escaped single quote within literals
q = "SELECT pkid FROM numplan WHERE description = 'O''Brien''s line'"
result = validate_select(q)
assert "O''Brien''s line" in result
def test_keyword_just_outside_literal_blocked(self):
# The literal 'safe text' is fine; the trailing DROP is not.
with pytest.raises(SqlValidationError, match="DROP"):
validate_select("SELECT 1 FROM device WHERE x = 'safe text' OR DROP")
def test_multiple_literals(self):
q = "SELECT 1 FROM numplan WHERE name = 'CALL' AND description = 'UPDATE pending'"
assert validate_select(q)
class TestLiteralPreservedInOutput:
"""Hamilton review CRITICAL #1: comment-strip mutated string literals.
The query SENT to AXL must preserve the literal contents byte-for-byte.
Previously, the comment-strip pass ran before the literal-aware pass,
so `--` or `/* */` inside a quoted string were silently eaten on the
way to the cluster. An LLM dialing `description LIKE '%-- old%'` got
a different query than it asked for.
"""
def test_dash_dash_inside_literal_preserved(self):
q = "SELECT * FROM numplan WHERE description = 'Smith -- old line'"
result = validate_select(q)
assert "Smith -- old line" in result, (
f"line-comment marker inside literal must NOT be stripped; got: {result!r}"
)
def test_block_comment_marker_inside_literal_preserved(self):
q = "SELECT * FROM device WHERE name = 'before /* still in literal */ after'"
result = validate_select(q)
assert "/* still in literal */" in result
assert "before" in result and "after" in result
def test_like_pattern_with_dash_dash_preserved(self):
# Real-world case: an LLM searches for descriptions containing "--"
q = "SELECT pkid FROM numplan WHERE description LIKE '%-- old%'"
result = validate_select(q)
assert "'%-- old%'" in result
def test_actual_line_comment_outside_literal_still_handled(self):
# An actual --comment outside any literal is fine (AXL handles it),
# and the keyword check ignores it.
q = "SELECT 1 FROM device -- a real comment at the end"
result = validate_select(q)
# We don't strip from output, so the comment stays in the returned text.
# The important thing is the validator passes and a forbidden keyword
# in the comment wouldn't trip the check (covered separately).
assert "SELECT 1 FROM device" in result
def test_forbidden_keyword_inside_real_comment_does_not_trip(self):
# Real comment, with a forbidden keyword in it, should not trip the validator
q = "SELECT 1 FROM device -- TODO: someone DELETE the old test data"
result = validate_select(q)
assert "SELECT 1" in result
def test_block_literal_with_drop_inside_preserved(self):
q = "SELECT 1 FROM numplan WHERE description = 'log: DROP detected'"
result = validate_select(q)
assert "'log: DROP detected'" in result

View File

@ -0,0 +1,48 @@
"""Hamilton review MINOR #6: `_to_int` silently coerced bad values to None.
Sort fields built on `_to_int` returns then defaulted None to 0, which
jumbled the failover order in the displayed result. Fix: when the conversion
fails, log to stderr (so an operator can see) and return None but the
caller code path that does the sort now uses a stable tie-breaker that
doesn't silently rewrite real-zero into "no value."
"""
import sys
from io import StringIO
from mcaxl.route_plan import _to_int
def test_to_int_passthrough_normal():
assert _to_int("5") == 5
assert _to_int(7) == 7
def test_to_int_none_returns_none_silently():
"""Real Nones are valid (column not set) — don't log noise for them."""
captured = StringIO()
real_stderr = sys.stderr
sys.stderr = captured
try:
assert _to_int(None) is None
finally:
sys.stderr = real_stderr
assert "warning" not in captured.getvalue().lower()
def test_to_int_bad_value_logs_warning():
"""A non-numeric string from the cluster (data corruption / unexpected
type) should be loud enough for an operator to notice in stderr."""
captured = StringIO()
real_stderr = sys.stderr
sys.stderr = captured
try:
result = _to_int("not-a-number")
finally:
sys.stderr = real_stderr
assert result is None
output = captured.getvalue()
assert "not-a-number" in output, (
f"unexpected non-numeric value should be logged with the offending value; "
f"got stderr: {output!r}"
)

View File

@ -2,7 +2,7 @@
import pytest
from mcp_cucm_axl.route_plan import _pattern_matches_number, _wildcard_to_regex
from mcaxl.route_plan import _pattern_matches_number, _wildcard_to_regex
class TestLiteralPatterns:
@ -86,12 +86,69 @@ class TestEdgeCases:
class TestRegexConversion:
def test_X_to_digit_class(self):
# Bounded after Hamilton MAJOR #4 — `X` still matches a single digit
assert _wildcard_to_regex("X") == r"^\d$"
def test_bang_to_one_or_more_digits(self):
assert _wildcard_to_regex("!") == r"^\d+$"
def test_bang_to_bounded_digits(self):
# Bounded after Hamilton MAJOR #4 — was \d+, now \d{1,N}.
# Adjacent !!! used to compile to (\d+)(\d+)(\d+) which has
# exponential backtracking on near-miss inputs.
regex = _wildcard_to_regex("!")
# Must be anchored, must contain a digit class with an upper bound.
assert regex.startswith("^") and regex.endswith("$")
assert r"\d{1," in regex, (
f"`!` must compile to bounded `\\d{{1,N}}` to prevent "
f"catastrophic backtracking; got: {regex}"
)
def test_anchored(self):
regex = _wildcard_to_regex("9XXX")
assert regex.startswith("^")
assert regex.endswith("$")
class TestUnclosedBracketIsExplicitError:
"""Hamilton review MAJOR #4 (part 2): unclosed `[` used to silently
fall back to treating the bracket as a literal. That produced wrong
matches with no warning. Fix: surface the malformed pattern as an
explicit error so the caller can flag it.
"""
def test_unclosed_bracket_raises(self):
import pytest as _pytest
with _pytest.raises(ValueError, match="bracket"):
_wildcard_to_regex("[0-9")
def test_unclosed_bracket_in_pattern_match_returns_false(self):
# _pattern_matches_number must catch the ValueError from the
# malformed pattern and return False (so a single bad pattern
# doesn't crash translation_chain).
assert _pattern_matches_number("[0-9", "1") is False
def test_well_formed_bracket_still_works(self):
# Sanity: the fix shouldn't break legitimate character classes
assert _pattern_matches_number("[2-9]XX", "456")
assert not _pattern_matches_number("[2-9]XX", "156")
class TestRegexBacktrackingBound:
"""Hamilton review MAJOR #4 (part 1): adjacent `!` wildcards used to
compile to `\\d+\\d+\\d+...` which is exponentially slow on near-miss
input. Bounded `\\d{1,N}` keeps it polynomial.
"""
def test_pathological_pattern_completes_quickly(self):
# 10 adjacent `!` matched against a long near-miss number.
# Pre-fix this could take seconds; bounded should finish in ms.
import time
pat = "!" * 10
# 30 digits + a trailing letter — guarantees no full match
num = "1" * 30 + "X"
t0 = time.monotonic()
result = _pattern_matches_number(pat, num)
elapsed = time.monotonic() - t0
assert result is False
assert elapsed < 0.5, (
f"pathological `!` chain must finish quickly even on near-miss; "
f"took {elapsed:.3f}s"
)

54
uv.lock generated
View File

@ -792,33 +792,8 @@ wheels = [
]
[[package]]
name = "mcp"
version = "1.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
]
[[package]]
name = "mcp-cucm-axl"
version = "0.1.0"
name = "mcaxl"
version = "2026.4.27"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },
@ -846,6 +821,31 @@ requires-dist = [
]
provides-extras = ["test"]
[[package]]
name = "mcp"
version = "1.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"