Compare commits
10 Commits
e3fb10cb4b
...
ca6956e826
| Author | SHA1 | Date | |
|---|---|---|---|
| ca6956e826 | |||
| 39d4b29392 | |||
| 9e5c195ce7 | |||
| 8815db06d8 | |||
| 8aaeb04417 | |||
| e6aa075793 | |||
| 2690c2225b | |||
| 90227ab391 | |||
| dee5fdacda | |||
| 82d8fbe563 |
@ -6,7 +6,7 @@
|
||||
"run",
|
||||
"--directory",
|
||||
"/home/rpm/bingham/axl",
|
||||
"mcp-cucm-axl"
|
||||
"mcaxl"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
67
CHANGELOG.md
Normal file
67
CHANGELOG.md
Normal 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
21
LICENSE
Normal 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
213
README.md
@ -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)
|
||||
|
||||
278
docs/query-patterns/sip-trunk-report.md
Normal file
278
docs/query-patterns/sip-trunk-report.md
Normal 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
|
||||
@ -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
5
src/mcaxl/__init__.py
Normal 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
206
src/mcaxl/cache.py
Normal 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
|
||||
@ -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 ----
|
||||
|
||||
@ -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,
|
||||
)
|
||||
44
src/mcaxl/prompts/__init__.py
Normal file
44
src/mcaxl/prompts/__init__.py
Normal 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",
|
||||
]
|
||||
73
src/mcaxl/prompts/_common.py
Normal file
73
src/mcaxl/prompts/_common.py
Normal 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)
|
||||
76
src/mcaxl/prompts/audit_routing.py
Normal file
76
src/mcaxl/prompts/audit_routing.py
Normal 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.
|
||||
"""
|
||||
52
src/mcaxl/prompts/cucm_sql_help.py
Normal file
52
src/mcaxl/prompts/cucm_sql_help.py
Normal 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.
|
||||
"""
|
||||
207
src/mcaxl/prompts/hunt_pilot_audit.py
Normal file
207
src/mcaxl/prompts/hunt_pilot_audit.py
Normal 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.
|
||||
"""
|
||||
181
src/mcaxl/prompts/inbound_did_audit.py
Normal file
181
src/mcaxl/prompts/inbound_did_audit.py
Normal 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.
|
||||
"""
|
||||
60
src/mcaxl/prompts/investigate_pattern.py
Normal file
60
src/mcaxl/prompts/investigate_pattern.py
Normal 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}
|
||||
"""
|
||||
236
src/mcaxl/prompts/phone_inventory_report.py
Normal file
236
src/mcaxl/prompts/phone_inventory_report.py
Normal 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.
|
||||
"""
|
||||
51
src/mcaxl/prompts/route_plan_overview.py
Normal file
51
src/mcaxl/prompts/route_plan_overview.py
Normal 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.
|
||||
"""
|
||||
161
src/mcaxl/prompts/sip_trunk_report.py
Normal file
161
src/mcaxl/prompts/sip_trunk_report.py
Normal 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.
|
||||
"""
|
||||
204
src/mcaxl/prompts/user_audit.py
Normal file
204
src/mcaxl/prompts/user_audit.py
Normal 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
192
src/mcaxl/prompts/whoami.py
Normal 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
407
src/mcaxl/risport.py
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'")
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
@ -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
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
@ -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,
|
||||
@ -1,5 +0,0 @@
|
||||
"""mcp-cucm-axl — read-only MCP server for CUCM 15 AXL."""
|
||||
|
||||
from .server import main
|
||||
|
||||
__all__ = ["main"]
|
||||
@ -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
|
||||
@ -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"
|
||||
|
||||
155
tests/test_client_recovery.py
Normal file
155
tests/test_client_recovery.py
Normal 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
319
tests/test_css_impact.py
Normal 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())
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_cucm_axl.normalize import (
|
||||
from mcaxl.normalize import (
|
||||
BOOLEAN_COLUMNS,
|
||||
normalize_bool,
|
||||
normalize_row,
|
||||
|
||||
291
tests/test_prompts_package.py
Normal file
291
tests/test_prompts_package.py
Normal 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
222
tests/test_risport.py
Normal 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>") == "<bad>"
|
||||
assert _escape_xml("a&b") == "a&b"
|
||||
assert _escape_xml('"x"') == ""x""
|
||||
assert _escape_xml("'y'") == "'y'"
|
||||
|
||||
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 "'" in env
|
||||
assert "<" in env
|
||||
assert ">" 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")
|
||||
40
tests/test_server_consistency.py
Normal file
40
tests/test_server_consistency.py
Normal 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
|
||||
@ -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
|
||||
|
||||
48
tests/test_to_int_diagnostic.py
Normal file
48
tests/test_to_int_diagnostic.py
Normal 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}"
|
||||
)
|
||||
@ -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
54
uv.lock
generated
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user