Rename to mcaxl + scrub for public PyPI release

Renames the package from `mcp-cucm-axl` to `mcaxl` to fit the
operator's mc<interface> naming convention (mcusb, mcaxl, …),
and scrubs Bingham-specific defaults so the package works for
anyone, anywhere.

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

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

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

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

Build verification:
  - `uv build` produces clean wheel + sdist
  - Wheel: 22 source files, 195KB total, no Bingham-specific files
  - Sdist excludes verified: no CLAUDE.md, no axlsqltoolkit.zip
  - Entry point: `mcaxl = mcaxl.server:main`
  - Package installs as mcaxl==2026.4.27
This commit is contained in:
Ryan Malloy 2026-04-27 12:53:54 -06:00
parent 39d4b29392
commit ca6956e826
41 changed files with 358 additions and 210 deletions

View File

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

67
CHANGELOG.md Normal file
View File

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

21
LICENSE Normal file
View File

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

237
README.md
View File

@ -1,86 +1,99 @@
# mcp-cucm-axl
# mcaxl
Read-only MCP server for **Cisco Unified CM 15** AXL — built for LLM-driven
cluster auditing, with a particular focus on the **Route Plan Report**:
partitions, calling search spaces, route patterns, translation patterns,
called/calling party transformations, and digit-discard instructions.
Read-only MCP server for **Cisco Unified Communications Manager (CUCM)**
exposes the AXL SOAP API and RisPort70 real-time registration state to
LLMs for dial-plan analysis, configuration auditing, and impact analysis.
> Tested against CUCM 15.0.1.12900. Should work on any CUCM 12.5+.
## Why this exists
CUCM's admin UI is great for one-config-at-a-time work but painful for
audit / discovery questions like:
- "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
@ -93,17 +106,6 @@ opens this directory.
| `cache_stats()`, `cache_clear(pattern=None)` | Cache plumbing |
| `health()` | Subsystem self-check (cache / AXL / docs / RisPort init state) |
### Real-time device registration (RisPort70)
Complementary to AXL — AXL tells you what's *configured*, RisPort tells you
what's *currently registered*. The audit-relevant cross-reference is
"configured but unregistered" (orphan signal).
| 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: registered / unregistered / rejected counts across Phone, Gateway, SIPTrunk, HuntList, etc. |
### Route plan
| Tool | Purpose |
@ -112,62 +114,75 @@ what's *currently registered*. The audit-relevant cross-reference is
| `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 registration state
- `user_audit(focus="full")` — end users + application users + role assignments
- `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)
## Scope and complement
### Optional: schema-grounded prompt enrichment
This server is **audit-focused**: read-only queries against AXL plus
RisPort cross-reference for registration state. It does *not* cover
operational debugging (logs, packet capture, perfmon counters,
service control, certificates, backups).
For those, install [`@calltelemetry/cisco-cucm-mcp`](https://github.com/calltelemetry/cisco-cucm-mcp)
alongside this server:
```bash
claude mcp add cucm-ops -- npx -y @calltelemetry/cisco-cucm-mcp@latest
```
The two servers are **complementary**, not competing — they answer
different questions and use different CUCM APIs (AXL + RisPort here;
DIME + RisPort + PerfMon + ControlCenter + SSH there). An LLM with
both servers can compose audit findings (this server) with operational
state (theirs) — e.g., *"audit found CSS X has 0 references AND
RisPort shows zero phones currently registered against any device pool
that inherits it → confirmed safe to delete."*
Set `CISCO_DOCS_INDEX_PATH` to a directory containing `chunks.jsonl`
and `index_meta.json` (produced by the
[`mcp-cisco-docs`](https://github.com/...) indexer or any compatible
embedding pipeline) to have prompts pull relevant Cisco documentation
chunks inline. Without this, prompts gracefully degrade to a fallback
notice instructing the LLM to use the sibling cisco-docs server's
`search_docs` tool.
## Cache
Responses are cached in SQLite at `~/.cache/mcp-cucm-axl/responses/axl_responses.sqlite`.
Cache survives restarts. Clear with `cache_clear()` after a known config change.
Responses are cached in SQLite at
`~/.cache/mcaxl/responses/axl_responses.sqlite`. The cache is
**cluster-isolated** by SHA-256 of `AXL_URL` — pointing the server
at a different cluster never serves stale data from a previous one.
Cache survives restarts. Clear with `cache_clear()` after a known
config change.
## Notes
## Caveats
- `route_translation_chain` does literal/prefix matching only. CUCM's actual
matcher evaluates wildcards (`X`, `!`, `[0-9]`, etc.) and selects the
longest match. Treat results as "patterns to investigate" rather than
"definitive route."
- Pattern type codes (`tkpatternusage`) used by `route_patterns(kind=...)` are
stable across CUCM versions but enumerated against the `typepatternusage`
table at query time, so any cluster-specific custom types still work.
- `route_translation_chain` evaluates CUCM wildcards (`X`, `!`, `[0-9]`,
`@`, `\+`) but does *not* model route-filter constraints on `@`
patterns. Use as guidance, not authoritative.
- The package's `recordingprofile` / `usageprofile` / `vipre164transformation`
reference categories were schema-verified against CUCM 15. If a future
CUCM version adds new `fkcallingsearchspace_*` columns,
`route_devices_using_css`'s coverage will lag until the package is
updated. The
`test_complete_schema_coverage_against_known_columns` test enforces
the current snapshot — failing red surfaces the drift loudly.
## License
MIT. See `LICENSE`.
## Source
- Repo: [git.supported.systems/mcp/mcaxl](https://git.supported.systems/mcp/mcaxl)
- Issues: [git.supported.systems/mcp/mcaxl/issues](https://git.supported.systems/mcp/mcaxl/issues)
- Changelog: [`CHANGELOG.md`](./CHANGELOG.md)

View File

@ -7,7 +7,7 @@ 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/mcp_cucm_axl/prompts/` is the
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`).

View File

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

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

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

View File

@ -136,7 +136,7 @@ class AxlClient:
# 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(
@ -161,7 +161,7 @@ class AxlClient:
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,
)
@ -171,7 +171,7 @@ class AxlClient:
# the last error for diagnostics.
self._last_error = f"AXL connection failed: {e}"
print(
f"[mcp-cucm-axl] {self._last_error} (operational, will retry on next call)",
f"[mcaxl] {self._last_error} (operational, will retry on next call)",
file=sys.stderr,
flush=True,
)

View File

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

View File

@ -1,4 +1,4 @@
"""Schema-grounded conversation seeds for `mcp-cucm-axl`.
"""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
@ -8,7 +8,7 @@ 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/mcp_cucm_axl/prompts/<name>.py` exporting a `render()`.
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.

View File

@ -46,7 +46,7 @@ def docs_or_empty_msg() -> str:
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 /home/rpm/bingham/docs/src/assets/.cisco-docs-index/ exists. "
"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._"
)

View File

@ -17,7 +17,7 @@ def render(docs: "DocsIndex | None") -> str:
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
`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)

View File

@ -303,7 +303,7 @@ class RisPortClient:
self._session = session
print(
f"[mcp-cucm-axl] RisPort client ready: {self._url}",
f"[mcaxl] RisPort client ready: {self._url}",
file=sys.stderr,
flush=True,
)

View File

@ -362,7 +362,7 @@ def _to_int(v: object) -> int | None:
except (TypeError, ValueError):
import sys
print(
f"[mcp-cucm-axl] _to_int: unexpected non-numeric value {v!r} "
f"[mcaxl] _to_int: unexpected non-numeric value {v!r} "
f"(type {type(v).__name__}); returning None",
file=sys.stderr,
flush=True,

View File

@ -48,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
# ====================================================================
@ -398,7 +389,7 @@ def route_filters(name: str | None = None, include_members: bool = False) -> dic
# ====================================================================
# Prompts — schema-grounded conversation seeds
#
# Bodies live in `mcp_cucm_axl.prompts.<name>`. The shims below are the
# 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.
@ -512,12 +503,12 @@ def whoami(userid: str | None = None) -> str:
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:
@ -532,7 +523,7 @@ 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"))
@ -548,7 +539,7 @@ def main() -> None:
cluster_id=cluster_id,
)
print(
f"[mcp-cucm-axl] cache: {_cache.db_path} "
f"[mcaxl] cache: {_cache.db_path} "
f"(ttl={ttl}s, cluster_id={cluster_id})",
file=sys.stderr,
flush=True,

View File

@ -2,7 +2,7 @@
Resolution chain:
1. AXL_WSDL_PATH env var (explicit override) use it
2. ~/.cache/mcp-cucm-axl/wsdl/15.0/AXLAPI.wsdl use cached copy
2. ~/.cache/mcaxl/wsdl/15.0/AXLAPI.wsdl use cached copy
3. Auto-extract `schema/15.0/` from a Cisco AXL Toolkit zip:
- $AXL_WSDL_ZIP if set
- ./axlsqltoolkit.zip in the current working directory
@ -28,8 +28,8 @@ WSDL_VERSION = "15.0"
def cache_wsdl_dir(version: str = WSDL_VERSION) -> Path:
"""Return ~/.cache/mcp-cucm-axl/wsdl/<version>/."""
return Path(user_cache_dir("mcp-cucm-axl")) / "wsdl" / version
"""Return ~/.cache/mcaxl/wsdl/<version>/."""
return Path(user_cache_dir("mcaxl")) / "wsdl" / version
def _has_complete_wsdl(directory: Path) -> bool:
@ -59,7 +59,7 @@ def _try_extract_from_zip(version: str = WSDL_VERSION) -> Path | None:
target.mkdir(parents=True, exist_ok=True)
print(
f"[mcp-cucm-axl] extracting AXL schema {version} from {zip_path}",
f"[mcaxl] extracting AXL schema {version} from {zip_path}",
file=sys.stderr,
flush=True,
)
@ -71,7 +71,7 @@ def _try_extract_from_zip(version: str = WSDL_VERSION) -> Path | None:
member = f"schema/{version}/{fname}"
if member not in zf.namelist():
print(
f"[mcp-cucm-axl] zip missing member: {member}",
f"[mcaxl] zip missing member: {member}",
file=sys.stderr,
flush=True,
)
@ -80,13 +80,13 @@ def _try_extract_from_zip(version: str = WSDL_VERSION) -> Path | None:
(target / fname).write_bytes(data)
extracted.append(fname)
print(
f"[mcp-cucm-axl] extracted {len(extracted)} files into {target}",
f"[mcaxl] extracted {len(extracted)} files into {target}",
file=sys.stderr,
flush=True,
)
return target
except (zipfile.BadZipFile, OSError) as e:
print(f"[mcp-cucm-axl] zip extraction failed: {e}", file=sys.stderr, flush=True)
print(f"[mcaxl] zip extraction failed: {e}", file=sys.stderr, flush=True)
return None
@ -106,7 +106,7 @@ def resolve_wsdl_path() -> Path:
if not _has_complete_wsdl(p.parent):
missing = [f for f in WSDL_FILES if not (p.parent / f).exists()]
print(
f"[mcp-cucm-axl] warning: WSDL dir missing {missing} alongside {p.name}; "
f"[mcaxl] warning: WSDL dir missing {missing} alongside {p.name}; "
f"zeep may fail to resolve schema imports.",
file=sys.stderr,
flush=True,

View File

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

View File

@ -5,7 +5,7 @@ from pathlib import Path
import pytest
from mcp_cucm_axl.cache import AxlCache
from mcaxl.cache import AxlCache
@pytest.fixture

View File

@ -11,8 +11,8 @@ from pathlib import Path
import pytest
from mcp_cucm_axl.cache import AxlCache
from mcp_cucm_axl.client import AxlClient
from mcaxl.cache import AxlCache
from mcaxl.client import AxlClient
@pytest.fixture
@ -47,7 +47,7 @@ def test_operational_error_is_not_pinned(cache: AxlCache, monkeypatch):
# Force the zeep Client constructor inside _ensure_connected to raise.
# This simulates "WSDL fetch failed", "TLS handshake error", etc. —
# transient operational failures.
from mcp_cucm_axl import client as client_mod
from mcaxl import client as client_mod
def boom(*args, **kwargs):
raise ConnectionError("simulated transient network failure")
@ -96,7 +96,7 @@ def test_retry_config_default_three_retries(cache: AxlCache, monkeypatch):
monkeypatch.setenv("AXL_PASS", "test")
monkeypatch.setenv("AXL_VERIFY_TLS", "false")
# Stub Client construction so we exercise only the session/retry setup
from mcp_cucm_axl import client as client_mod
from mcaxl import client as client_mod
constructed = {}
@ -126,7 +126,7 @@ def test_retry_config_overridable_via_env(cache: AxlCache, monkeypatch):
monkeypatch.setenv("AXL_PASS", "test")
monkeypatch.setenv("AXL_RATE_LIMIT_RETRIES", "7")
from mcp_cucm_axl import client as client_mod
from mcaxl import client as client_mod
monkeypatch.setattr(client_mod, "Client", lambda *a, **kw: (_ for _ in ()).throw(ConnectionError("stub")))
client = AxlClient(cache)
@ -144,7 +144,7 @@ def test_retry_config_zero_disables(cache: AxlCache, monkeypatch):
monkeypatch.setenv("AXL_PASS", "test")
monkeypatch.setenv("AXL_RATE_LIMIT_RETRIES", "0")
from mcp_cucm_axl import client as client_mod
from mcaxl import client as client_mod
monkeypatch.setattr(client_mod, "Client", lambda *a, **kw: (_ for _ in ()).throw(ConnectionError("stub")))
client = AxlClient(cache)

View File

@ -14,7 +14,7 @@ state and act on it.
import pytest
from mcp_cucm_axl.route_plan import find_devices_using_css
from mcaxl.route_plan import find_devices_using_css
class FakeAxlClient:
@ -140,7 +140,7 @@ def test_css_not_found_returns_error_not_partial():
# referenced via these columns showed up as "0 references" — operator running
# impact analysis would conclude safe-to-delete and break outbound transforms.
from mcp_cucm_axl.route_plan import _CSS_REFERENCE_QUERIES
from mcaxl.route_plan import _CSS_REFERENCE_QUERIES
def test_issue_1_cgpntransform_column_enumerated():

View File

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

View File

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

View File

@ -10,8 +10,8 @@ from pathlib import Path
import pytest
from mcp_cucm_axl import prompts
from mcp_cucm_axl.docs_loader import DocsIndex
from mcaxl import prompts
from mcaxl.docs_loader import DocsIndex
@pytest.fixture
@ -151,7 +151,7 @@ def test_all_prompts_registered_in_server():
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 mcp_cucm_axl import server
from mcaxl import server
async def _list():
registered = await server.mcp.list_prompts()

View File

@ -9,7 +9,7 @@ import xml.etree.ElementTree as ET
import pytest
from mcp_cucm_axl.risport import (
from mcaxl.risport import (
DEVICE_STATUS_VALUES,
RisPortClient,
_build_select_envelope,

View File

@ -10,7 +10,7 @@ raise RuntimeError when their dependencies aren't initialized.
import pytest
from mcp_cucm_axl import server
from mcaxl import server
def test_cache_stats_raises_when_uninitialized(monkeypatch):

View File

@ -2,7 +2,7 @@
import pytest
from mcp_cucm_axl.sql_validator import validate_select, SqlValidationError
from mcaxl.sql_validator import validate_select, SqlValidationError
class TestSelectAccepted:

View File

@ -10,7 +10,7 @@ doesn't silently rewrite real-zero into "no value."
import sys
from io import StringIO
from mcp_cucm_axl.route_plan import _to_int
from mcaxl.route_plan import _to_int
def test_to_int_passthrough_normal():

View File

@ -2,7 +2,7 @@
import pytest
from mcp_cucm_axl.route_plan import _pattern_matches_number, _wildcard_to_regex
from mcaxl.route_plan import _pattern_matches_number, _wildcard_to_regex
class TestLiteralPatterns:

54
uv.lock generated
View File

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