docs: scaffold Starlight site at docs/

17-page Astro/Starlight site mirroring the bingham/cucx conventions
(telemetry off, devToolbar off, astro-icon + lucide, separate
custom.css, Diátaxis-structured sidebar with autogenerate per
directory). Green accent palette differentiates from bingham/cucx's
teal.

Pages by Diátaxis quadrant:
  - Getting Started (3): installation, configuration, first-audit
  - How-To (4): sip-trunk-report (port from docs/query-patterns/),
    route-plan-overview, investigate-pattern (mermaid flowchart),
    find-orphan-resources
  - Reference (4): tools (all 19), prompts (all 10), env-vars,
    cucm-schema-cheatsheet
  - Explanation (4): read-only-by-structure, cluster-isolated-cache,
    hamilton-review-patterns, pypi-yank-lesson

Build-verified clean (npm run build → 17 pages in 7.88s, pagefind
search index built across all pages, zero errors).

Legacy docs/query-patterns/sip-trunk-report.md kept in place — that
file ships in the published Python sdist's docs/ tree, deletion would
be a package change not just a docs-site change. The new how-to
version is a near-verbatim port.

Content gaps for follow-up: real cluster-output examples in tool/
prompt reference pages, verified CUCM 15 SQL in
find-orphan-resources.md, optional favicon.

Not yet wired for deployment (Caddyfile/Dockerfile out of scope for
v1). Local preview: cd docs && npm run dev.
This commit is contained in:
Ryan Malloy 2026-04-29 04:01:13 -06:00
parent 0691ba8c46
commit f060170e90
27 changed files with 11566 additions and 0 deletions

8
docs/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Astro / build output
.astro/
dist/
node_modules/
# Editor / OS
.DS_Store
*.log

133
docs/astro.config.mjs Normal file
View File

@ -0,0 +1,133 @@
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import icon from 'astro-icon';
import mermaid from 'astro-mermaid';
import starlightPageActions from 'starlight-page-actions';
// Reverse-proxy / HMR awareness. `DOMAIN` is injected by docker-compose
// (DEV_DOMAIN for the dev service, DOMAIN for prod). When unset, we
// assume plain local dev on http://localhost:4321.
const DOMAIN = process.env.DOMAIN || 'localhost';
const IS_BEHIND_PROXY = DOMAIN !== 'localhost';
export default defineConfig({
// Disabled per workspace convention.
telemetry: false,
devToolbar: { enabled: false },
site: IS_BEHIND_PROXY ? `https://${DOMAIN}` : undefined,
// Shiki doesn't ship a Cisco IOS grammar. Map `cisco` → `ini` (closest
// visual match — `!` line comments, "keyword value" shape). Silences
// build-time warnings for any Cisco code fences in narrative pages.
// `env` (dotenv files) → `bash` so the KEY=value syntax highlights.
markdown: {
shikiConfig: {
langAlias: {
cisco: 'ini',
env: 'bash',
},
},
},
integrations: [
// Mermaid must come BEFORE starlight — its remark/rehype plugin has
// to register before Starlight sets up its MDX pipeline. Renders
// client-side (no build-time SVG generation).
mermaid({
theme: 'default',
autoTheme: true, // follows light/dark mode toggle
}),
icon({
include: {
lucide: ['*'],
},
}),
starlight({
title: 'mcaxl docs',
description:
'Read-only MCP server for Cisco Unified Communications Manager (CUCM) — AXL SOAP API + RisPort70 audit. Documentation.',
logo: {
src: './src/assets/logo.svg',
replacesTitle: false,
},
customCss: ['./src/styles/custom.css'],
// Per-page action buttons: "Copy Markdown" + "View in Markdown"
// (.md route) + "Share" menus. Open-in-AI actions disabled because
// they assume the third-party AI can fetch the URL — fine here for
// a public site, but kept off to match the operator preference for
// copy-and-paste handoffs.
plugins: [
starlightPageActions({
actions: {
chatgpt: false,
claude: false,
},
}),
],
lastUpdated: true,
pagination: true,
social: [
{
icon: 'external',
label: 'PyPI',
href: 'https://pypi.org/project/mcaxl/',
},
{
icon: 'external',
label: 'Gitea repo',
href: 'https://git.supported.systems/mcp/mcaxl',
},
],
editLink: {
baseUrl: 'https://git.supported.systems/mcp/mcaxl/_edit/main/docs/',
},
sidebar: [
{
label: 'Overview',
items: [
{ label: 'Home', link: '/' },
],
},
{
label: 'Getting started',
collapsed: false,
autogenerate: { directory: 'getting-started' },
},
{
label: 'How-to guides',
collapsed: false,
autogenerate: { directory: 'how-to' },
},
{
label: 'Reference',
collapsed: false,
autogenerate: { directory: 'reference' },
},
{
label: 'Explanation',
collapsed: true,
autogenerate: { directory: 'explanation' },
},
],
}),
],
vite: {
server: {
host: '0.0.0.0',
// HMR survives behind a Caddy-terminated TLS reverse proxy when
// configured this way. Leaves localhost dev untouched.
hmr: IS_BEHIND_PROXY
? {
host: DOMAIN,
protocol: 'wss',
clientPort: 443,
}
: undefined,
},
},
});

8944
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
docs/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "mcaxl-docs",
"version": "2026.04.28",
"private": true,
"type": "module",
"description": "mcaxl — read-only CUCM/AXL audit MCP server documentation site",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"check": "astro check"
},
"dependencies": {
"@astrojs/starlight": "^0.38.3",
"@iconify-json/lucide": "^1.2.102",
"astro": "^6.1.8",
"astro-icon": "^1.1.5",
"astro-mermaid": "^2.0.1",
"sharp": "^0.33.5",
"starlight-page-actions": "^0.6.0"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}

6
docs/src/assets/logo.svg Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" aria-hidden="true">
<rect x="2" y="2" width="36" height="36" rx="8" fill="#1f6f5c"/>
<path d="M10 12 L20 28 L30 12" stroke="#f3efe6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="20" cy="28" r="2.4" fill="#f3efe6"/>
</svg>

After

Width:  |  Height:  |  Size: 372 B

View File

@ -0,0 +1,28 @@
---
import { Icon } from 'astro-icon/components';
type Card = {
title: string;
description: string;
href: string;
icon: string; // lucide icon name, e.g. "book-open", "wrench"
};
interface Props {
cards: Card[];
}
const { cards } = Astro.props;
---
<div class="mc-card-grid">
{
cards.map((card) => (
<a class="mc-card" href={card.href}>
<Icon name={`lucide:${card.icon}`} class="mc-card-icon" />
<h3>{card.title}</h3>
<p>{card.description}</p>
</a>
))
}
</div>

View File

@ -0,0 +1,46 @@
---
// Compact "what kind of page is this?" explainer for the landing page.
// Diátaxis quadrants: tutorials, how-to guides, reference, explanation.
import { Icon } from 'astro-icon/components';
const rows = [
{
icon: 'graduation-cap',
title: 'Tutorials',
body: 'Step-by-step lessons. Start here if mcaxl is new to you.',
href: '/getting-started/installation/',
},
{
icon: 'wrench',
title: 'How-to guides',
body: 'Recipes for specific audit jobs — SIP trunk inventory, finding orphan resources.',
href: '/how-to/sip-trunk-report/',
},
{
icon: 'book-open',
title: 'Reference',
body: 'Tool surface, prompt list, environment variables, schema cheat-sheet.',
href: '/reference/tools/',
},
{
icon: 'lightbulb',
title: 'Explanation',
body: 'Why mcaxl is structured the way it is — design rationale and lessons learned.',
href: '/explanation/read-only-by-structure/',
},
];
---
<div class="mc-diataxis">
{
rows.map((row) => (
<a class="mc-diataxis-row" href={row.href}>
<Icon name={`lucide:${row.icon}`} class="mc-card-icon" />
<div>
<strong>{row.title}</strong>
<p>{row.body}</p>
</div>
</a>
))
}
</div>

View File

@ -0,0 +1,13 @@
import { defineCollection } from 'astro:content';
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
// Single Starlight docs collection. Schema is the stock Starlight one;
// no per-page custom frontmatter fields needed for v1. Add z.object({...})
// extensions here later if pages need typed metadata.
export const collections = {
docs: defineCollection({
loader: docsLoader(),
schema: docsSchema(),
}),
};

View File

@ -0,0 +1,121 @@
---
title: Cluster-isolated cache
description: Why the response cache is keyed by SHA-256 of AXL_URL, and what that protects against.
sidebar:
order: 2
---
`mcaxl` caches every tool's response to SQLite at
`~/.cache/mcaxl/responses/axl_responses.sqlite`. The cache key includes
a **cluster identifier** that's the SHA-256 hex digest of `AXL_URL`. This
is intentional, and it's the difference between a useful cache and a
landmine.
## The failure mode it prevents
Imagine the cache was keyed only by tool name and arguments — a
straightforward LRU. Now consider this workflow:
1. Operator works on **Cluster A** for a few hours. Cache fills with
`route_partitions()` results from Cluster A, `route_patterns()`,
`route_devices_using_css()` for various CSS names, etc.
2. Operator updates `.env` to point at **Cluster B**. Restarts the MCP server.
3. LLM calls `route_partitions()` against Cluster B.
4. Cache returns Cluster A's data because `(tool="route_partitions", args={})`
matches.
The LLM now has Cluster A's partition list while believing it's
auditing Cluster B. Findings are wrong; recommendations are wrong;
worst case, an operator acts on those findings against the wrong
cluster.
## How the isolation works
The cache key is `(cluster_id, method, args_hash)`, where:
- `cluster_id = sha256(AXL_URL).hexdigest()[:16]`
- `method` is the AXL operation name (e.g. `executeSQLQuery`)
- `args_hash` is a SHA-256 of the canonicalized arguments JSON
When the server starts, it computes `cluster_id` once and binds it to
the cache instance. Every read and every write filters by that
cluster_id. Pointing at a different cluster computes a different
hash — the new server instance literally cannot see the old cluster's
rows.
## What you see in `health()`
The `health` tool surfaces the current `cluster_id` so an operator can
verify which cluster's cache they're hitting:
```json
{
"cache": true,
"axl": true,
"cache_cluster_id": "8f2a9b1c4e7d6309"
}
```
If you change `AXL_URL` and the `cache_cluster_id` doesn't change after
restart, the env var didn't take effect — check whether `.env` was
actually re-read or whether a shell-exported value is overriding.
## Why hash, not literal URL
Two reasons:
1. **Privacy in shared cache locations**`~/.cache/mcaxl/` might be on
a multi-user system. The hash doesn't reveal cluster hostnames in a
directory listing the way a literal URL would.
2. **Stable across cosmetic URL changes** — except it's *not* stable,
and that's a feature: trailing slash differences, port-number
differences, IP-vs-FQDN — these all change the hash and force a
fresh cache. If two URLs *happen* to point at the same cluster,
that's a configuration ambiguity that's better surfaced as
different caches than papered over.
## TTL and invalidation
`AXL_CACHE_TTL=3600` (default) means rows expire 1 hour after write.
For audit work this is usually fine — CUCM config doesn't change
second-to-second, and a 1-hour-old `route_partitions()` result is
indistinguishable from a fresh one.
When you've made a known config change and want to force fresh queries:
```
cache_clear(method_pattern="route_%")
```
Cluster-scoped: only this cluster's `route_*` rows are cleared. Other
clusters' caches are untouched.
To clear everything for the current cluster:
```
cache_clear()
```
To wipe the entire SQLite file (all clusters): just delete it from disk.
The next server start will recreate it.
## What the cache stores
Every `axl_*` and `route_*` tool's response. RisPort70 results are
**not cached** — they're explicitly real-time state, and the whole point
of `device_registration_status` is that it changes minute to minute.
The `_cache: hit | miss` field on every cacheable response surfaces
whether the data is fresh or cached.
## Survives restarts
Because it's SQLite on disk, the cache survives MCP server restarts
and host reboots. This is intentional — most audit sessions span days
of intermittent work. Re-paying the AXL roundtrip cost on every fresh
session would burn budget for no benefit.
## See also
- [`cache_stats`](/reference/tools/#cache_stats) and [`cache_clear`](/reference/tools/#cache_clear) — runtime cache control
- [Environment variables](/reference/env-vars/#axl_cache_ttl) — TTL knob
- [`health`](/reference/tools/#health) — surfaces `cache_cluster_id` for verification

View File

@ -0,0 +1,145 @@
---
title: Hamilton review patterns
description: Why mcaxl shipped with seven hardening fixes and a schema-drift regression test.
sidebar:
order: 3
---
Before the public release, `mcaxl` went through a Hamilton-style design
review — a deliberate adversarial pass that surfaced seven distinct
issues across two Critical, three Major, and two Minor severities. Each
finding became a patched code path *and* a regression test. This page
catalogs the patterns that came out of that review because they shape
how new tools should be written going forward.
## The seven findings
(Headlines paraphrased from the review notes; specifics live in the
`tests/` directory's regression-test names.)
### Critical #1 — Cache poisoning across clusters
**Finding:** the original cache schema had no cluster identifier. An
operator who switched `AXL_URL` between sessions would see Cluster A's
data when querying Cluster B.
**Fix:** SHA-256 of `AXL_URL` becomes part of every cache row's primary
key. See [Cluster-isolated cache](/explanation/cluster-isolated-cache/)
for the full design.
**Regression test:** seeds two cluster_ids, writes rows under both,
asserts that lookups under one cluster_id never see the other's rows.
### Critical #2 — Incomplete CSS reference coverage
**Finding:** `route_devices_using_css` originally checked ~12
`fkcallingsearchspace_*` columns. The CUCM 15 schema has **71** such
columns spread across phones, gateways, trunks, translation patterns,
route patterns, hunt pilots, voicemail ports, MGCP endpoints, and
more. A CSS could be reported as "unreferenced" while actually used by
a hunt pilot or voicemail port the tool didn't check.
**Fix:** the tool now walks all 71 known columns and reports findings
per category.
**Regression test:** `test_complete_schema_coverage_against_known_columns`
asserts the tool's hardcoded column list matches the actual schema. If
a future CUCM version adds new CSS-bearing columns, the test fails red
and forces an explicit update — better to surface drift loudly than to
silently miss references.
### Major #1`route_translation_chain` ignores route filters
**Finding:** `@` patterns are constrained by route filters that match
specific dial-plan slices (international, toll, local, etc.).
`route_translation_chain` matched `@` patterns greedily, returning
matches that the route filter would actually exclude at call-time.
**Fix:** documented as a known limitation; the tool emits a warning when
it returns an `@`-pattern match. Modeling route filters in the matcher
is on the roadmap but non-trivial.
**Regression test:** asserts the warning fires for `@`-pattern matches.
### Major #2 — RisPort SOAP retry on transient failures
**Finding:** RisPort70 throttles aggressively under load (502, 503, 504
responses are common on a busy cluster). The original code didn't
retry; one transient blip aborted a multi-page enumeration.
**Fix:** exponential backoff retry, configurable via
`AXL_RATE_LIMIT_RETRIES`. Default 3 covers most cases.
**Regression test:** mocks a 503 response twice followed by 200 success;
asserts the tool returns the eventual success.
### Major #3 — Inconsistent error shapes between tools
**Finding:** some tools returned `{"error": "..."}` for failures while
others raised `RuntimeError`. LLMs had to handle two patterns.
**Fix:** all tool failures now raise `RuntimeError` uniformly. `_require_cache()`
matches `_client()`'s behavior.
**Regression test:** every tool's failure path is exercised; all raise
`RuntimeError`.
### Minor #1 — Trailing semicolon in `axl_sql` queries
**Finding:** SQL queries with trailing semicolons sent through to AXL
returned a cryptic error. LLMs reasonably include trailing semicolons.
**Fix:** the validator strips trailing semicolons before submission.
### Minor #2 — Cache stats counted expired entries
**Finding:** `cache_stats()` reported total rows in the SQLite table,
including expired rows that wouldn't be served. Misleading.
**Fix:** stats now report `total_entries` and `live_entries` separately.
## Patterns that emerged
### 1. Schema coverage tests guard against drift
The CSS-coverage finding generalized: any tool that walks a known
column list, table list, or enum list should have a regression test
that asserts the list matches the live schema. Drift is the silent
killer; loud test failures are the alternative.
### 2. Failures raise, never return error dicts
`raise RuntimeError` consistently. LLMs handle exceptions cleanly; they
get tangled by inspect-then-branch on `.get("error")` patterns. One
pattern, applied everywhere.
### 3. Cluster identity is part of every cache key
Anything that gets cached gets a cluster_id prefix. The pattern is
encoded in the cache layer so individual tools don't have to think
about it.
### 4. Known limitations are documented, not silently swallowed
The route-filter limitation in `route_translation_chain` is documented
in three places: the tool docstring, the README, and the
[How-to: Investigate a pattern](/how-to/investigate-pattern/) page.
Ambiguity that the tool doesn't fully resolve should be loud.
### 5. Defense-in-depth at the boundary
The structural read-only guarantee plus the SQL validator plus the
service-account role binding is three independent layers. Any one of
them should be sufficient; together they're robust.
## Total test count
The full test suite is **155 unit tests** with the schema-drift guard
included. Every Hamilton finding has at least one regression test
named after the finding.
## See also
- [Read-only by structure](/explanation/read-only-by-structure/) — the structural guarantee Critical #2 builds on
- [Cluster-isolated cache](/explanation/cluster-isolated-cache/) — the design that addresses Critical #1
- [`route_devices_using_css`](/reference/tools/#route_devices_using_css) — the 71-column tool that Critical #2 produced

View File

@ -0,0 +1,143 @@
---
title: The PyPI yank-and-republish lesson
description: How a PII leak in a published sdist taught us to audit the unpacked tarball, not just curated source paths.
sidebar:
order: 4
---
The story behind version `2026.04.27.1` — what we shipped, what
leaked, what we did about it, and the durable change that came out of
it. This is here for the next person who reaches a "ready to publish"
moment thinking the audit is done.
## What happened
`mcaxl` v`2026.04.27` was the first public PyPI release. The
pre-publish PII audit ran against `src/`, `tests/`, `pyproject.toml`,
and `README.md` — the curated source paths. It returned empty. We
published.
A stricter post-publish audit, this time against the **unpacked sdist**,
returned hits. Two of them:
1. **`docs/query-patterns/sip-trunk-report.md`** had a "Live result
snapshot" section with the test cluster's actual SIP trunk
inventory. Real internal hostnames. Real internal IPs. The
query-pattern doc itself was fine to ship — operationally rich,
useful — but the snapshot section had been added during development
for "look at the real output" and never scrubbed before publish.
2. **`.mcp.json`** had a local filesystem path in it. Dev artifact, not
meant for distribution. Hatchling included it in the sdist because
nothing in `[tool.hatch.build.targets.sdist] exclude` told it not to.
Neither leak was catastrophic — the hostnames and paths weren't
credentials, weren't tokens, didn't grant access. But they fingerprinted
a specific cluster, and the principle that "PyPI is immutable per
version" means that fingerprint would have lived on the public index
permanently.
## The recovery
PyPI's web UI has a per-version Yank action that marks a version as
"do not install" without removing the file. We yanked v`2026.04.27`
within minutes of confirming the leak.
For genuine PII removal (versus just yank), email
`admin@pypi.org` — they will sometimes do full file deletion, but it
takes days, not minutes. We escalated for full deletion.
We bumped the version to `2026.04.27.1` (CalVer post-release suffix —
PEP 440 compatible), scrubbed the snapshot section out of the
sip-trunk doc, added `.mcp.json` to the sdist exclude list, **re-ran the
unpacked-sdist audit** until it returned clean, and republished.
Total time from "uh oh" to "fixed and republished": about an hour. Most
of that was waiting for confidence in the new audit, not the
mechanical work.
## What we changed permanently
### 1. The audit runs against the unpacked sdist
The pre-publish audit script now does:
```bash
rm -rf dist/ /tmp/sdist-audit
uv build
mkdir -p /tmp/sdist-audit
tar -xzf dist/*.tar.gz -C /tmp/sdist-audit
grep -rnEi 'site-token|10\.[0-9]+|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|customer-name|/home/' /tmp/sdist-audit/
```
The unpacked-sdist grep is **non-negotiable** because the sdist's blast
surface is larger than the curated source-path enumeration. Hatchling
pulls in `docs/`, top-level dotfiles like `.mcp.json` and `.gitignore`,
`uv.lock`, `CHANGELOG.md`, and auto-generated files like `PKG-INFO`.
Whatever the unpacked-sdist grep returns is what ships to PyPI
permanently. Empty result = safe to publish. Anything else = scrub the
source, rebuild, re-audit.
### 2. Structural defense in `pyproject.toml`
Belt-and-suspenders alongside the audit script:
```toml
[tool.hatch.build.targets.sdist]
exclude = [
"CLAUDE.md",
".env", ".env.local",
".mcp.json",
"axlsqltoolkit.zip",
"audits/",
"tests/",
".pytest_cache/", ".ruff_cache/",
"dist/", "build/",
]
```
The exclude list prevents inclusion at build time; the audit verifies
after build. Both are necessary because new dev artifacts may appear
that the exclude list hasn't been updated for — the audit catches the
gap.
### 3. CalVer post-release for same-day fixes
PyPI is immutable per version. You cannot fix a version after publish.
The convention encoded going forward:
- Initial release: `version = "YYYY.MM.DD"`
- Same-day fix: bump to `YYYY.MM.DD.1`, `YYYY.MM.DD.2`, etc. (PEP 440
post-release suffix)
- Test thoroughly before *that* publish since the lesson is fresh
### 4. `uv publish` is a one-shot commit
Hard-won corollary: `uv publish` does not abort mid-upload. Once the
HTTP request is in flight, killing the local process (Ctrl-C, harness
interrupt) does **not** recall the file from PyPI — the upload likely
completed before the kill arrived. Treat any `uv publish` invocation
as committed unless you see a clean error from the server.
The "Request interrupted" message in our LLM harness only kills the
local process, NOT the in-flight HTTP request.
## Why this is in the explanation section
This is documentation, not a runbook. The runbook for "release a new
version" lives in the project repo's `RELEASING.md`. This page exists
because the *reasoning* — why the audit shape changed, what classes of
mistake it now catches — is more valuable than the mechanical script.
A new operator reading this should come away knowing:
1. Curated grep paths are necessary but not sufficient
2. The sdist's blast surface is larger than `src/` + `tests/`
3. PyPI is immutable, so audit *before* publish, not after
4. Yank-and-republish via post-release is the recovery, not a fix
## See also
- [`pyproject.toml`](https://git.supported.systems/mcp/mcaxl/src/branch/main/pyproject.toml) — current sdist exclude list
- [`CHANGELOG.md`](https://git.supported.systems/mcp/mcaxl/src/branch/main/CHANGELOG.md) — `2026.04.27.1` retrospective entry
- The python.md rule in operator-private notes that codifies this audit pattern across all our PyPI-published packages

View File

@ -0,0 +1,95 @@
---
title: Read-only by structure
description: Why mcaxl is structurally incapable of mutating CUCM, not just policy-restricted.
sidebar:
order: 1
---
`mcaxl` is read-only — and not by *policy*. It's read-only by *absence*.
The server **never registers** an AXL write method. There is no
`executeSQLUpdate` tool, no `add*` / `update*` / `remove*` / `apply*` /
`reset*` / `restart*` tool. The mutating side of AXL is structurally
absent from the tool surface that gets exposed to the LLM.
This matters because the alternative — *runtime sanitization* of an LLM's
chosen tool calls — is a moving target. Every model release shifts the
boundary of what an LLM might try. Sanitization in code becomes a
forever-game of catching new escape hatches.
Absence is timeless. If the tool isn't registered, the LLM can't
invoke it.
## What that looks like in code
`server.py` registers exactly the 19 tools listed in the
[Tool reference](/reference/tools/). Each is an `@mcp.tool` decorator on
a function whose body calls one of the read-only paths in `client.py`:
- `client.execute_sql_query()` — wraps AXL's `executeSQLQuery` (read-only)
- `client.list_informix_tables()` — wraps a `executeSQLQuery` against `systables`
- `client.describe_informix_table()` — same, against `syscolumns`
- `client.get_ccm_version()` — wraps `getCCMVersion` (read-only)
There is no `client.execute_sql_update()`, no `client.add_*()`. The
methods don't exist; the import would fail.
## Defense-in-depth: SQL validator
Even though writes can't reach AXL via this server, the SQL validator
in `sql_validator.py` rejects non-`SELECT` / `WITH` queries client-side
before they go anywhere. This is belt-and-suspenders — if a future
refactor accidentally exposed `executeSQLQuery` to a non-`SELECT`
string (e.g. a stored-procedure call hidden inside a CTE), the
validator would catch it before the request left the host.
The validator's rules:
1. After stripping comments and trimming whitespace, the query must
start with `SELECT` or `WITH` (case-insensitive)
2. The query must not contain any of: `INSERT`, `UPDATE`, `DELETE`,
`DROP`, `CREATE`, `ALTER`, `TRUNCATE`, `EXEC`, `EXECUTE`, `CALL`
3. Trailing semicolons are stripped before submission
This catches the obvious cases. It is not a SQL parser, and it doesn't
try to be — the structural read-only guarantee is the primary defense,
the validator is the secondary defense.
## Operational consequence: minimal-privilege service account
Because the server is structurally incapable of writes, the AXL service
account it uses can be granted **only** the
`Standard AXL Read Only API Access` role. Operators sometimes attach
the full `Standard AXL API Access` role (read-write) "for convenience";
`mcaxl` is incapable of using it.
If the cluster's existing AXL service account already has write roles
attached for other tooling, that's fine — `mcaxl` won't use them. But
when creating a *new* account specifically for `mcaxl`, give it the
read-only role only. The `whoami` prompt will surface a finding if the
account has write-capable roles.
## Why this matters more for LLM-driven tools
A human operator typing into a SQL prompt can be trusted not to
type `DROP TABLE device`. An LLM has read tens of thousands of tutorials
that include "and then to clean up, you can do DROP TABLE..." — the
prior is *much* higher that an unbounded SQL tool would, eventually,
under the right confluence of prompt context and reasoning chain,
issue a destructive query.
Structural read-only is the only durable answer. Whatever the LLM
*reasons* it should do, the *capability* to mutate is not present.
## The composability angle
Pairing `mcaxl` with `@calltelemetry/cisco-cucm-mcp` (which exposes
operational debugging — log collection, packet capture, perfmon) gives
an LLM session a much richer surface, but `cisco-cucm-mcp` also exposes
write paths (service restart, packet capture start/stop, etc.). Operators
who care about strict read-only audit isolation can run `mcaxl` alone;
operators who want compound findings can run both side-by-side and
configure `cisco-cucm-mcp` with appropriately scoped credentials.
The two servers' choices reflect their different scopes — `mcaxl`'s
scope is audit, so read-only by structure. `cisco-cucm-mcp`'s scope is
operations, where writes are part of the job.

View File

@ -0,0 +1,100 @@
---
title: Configuration
description: Environment variables, the AXL service account role binding, and TLS notes.
sidebar:
order: 2
---
`mcaxl` reads its configuration from environment variables. Most
operators keep a `.env` file in the working directory that the server
loads automatically (via `python-dotenv`). Anything in the actual
process environment overrides values from `.env`.
## Required variables
```env
AXL_URL=https://cucm-pub.example.com:8443/axl/
AXL_USER=your-axl-service-account
AXL_PASS=your-password
```
- `AXL_URL` must point to the **publisher** node. The AXL service runs on
the publisher only — pointing at a subscriber returns a misleading
empty-result error rather than a connection error.
- `AXL_URL` must end in `/axl/` (trailing slash). The SOAP envelope is
POSTed to that exact path.
- The default port is `8443/tcp`. Custom ports are uncommon; set them
explicitly in the URL if your cluster differs.
## Optional variables
```env
AXL_VERIFY_TLS=false # CUCM ships self-signed certs; default is 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 file location override
AXL_WSDL_ZIP= # explicit toolkit zip path override
CISCO_DOCS_INDEX_PATH= # for prompt enrichment; see below
```
See [Environment variables reference](/reference/env-vars/) for the
exhaustive list with defaults, units, and edge cases.
## TLS verification
CUCM ships a self-signed CA cert on every fresh install. Most clusters
never get this replaced with a real cert, so `AXL_VERIFY_TLS=false` is
the practical default. If your cluster has a CA-signed cert installed,
set `AXL_VERIFY_TLS=true` and `mcaxl` will validate against the
system trust store.
There is no `AXL_CA_BUNDLE` knob yet — if you need to validate against a
private CA, install the CA cert into your OS trust store (`update-ca-certificates`,
`security add-trusted-cert`, etc.) and `AXL_VERIFY_TLS=true` will pick it up.
## AXL service account role binding
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 — `mcaxl` is structurally incapable of using write permissions
even if the account has them.
To create a minimal-privilege account:
1. **User Management -> Application User -> Add New**
2. Set `userid` (e.g. `mcaxl`) and a strong password
3. **User Management -> Access Control Group -> Add New**
- Name: `mcaxl-readonly`
- Roles: `Standard AXL Read Only API Access`
4. Add the application user to the new access control group
The `whoami` prompt with no arguments will display the role chain for
the configured `AXL_USER` so you can verify the binding without leaving
your LLM session.
## 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` 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.
## Sample `.env`
```env
AXL_URL=https://cucm-pub.example.com:8443/axl/
AXL_USER=mcaxl
AXL_PASS=change-me
AXL_VERIFY_TLS=false
AXL_CACHE_TTL=3600
# Optional schema-doc enrichment
CISCO_DOCS_INDEX_PATH=/var/lib/cisco-docs-index/15.0/
```
## Next
- [Run your first audit](/getting-started/first-audit/) — orchestrate tools through the `route_plan_overview` prompt
- [Environment variables reference](/reference/env-vars/) — the exhaustive list

View File

@ -0,0 +1,117 @@
---
title: Your first audit
description: A 10-minute tour. Run route_plan_overview against a live cluster and read the output.
sidebar:
order: 3
---
This walkthrough assumes you've completed [Installation](/getting-started/installation/)
and [Configuration](/getting-started/configuration/) and have an
MCP-aware client connected to `mcaxl`. We'll use Claude Code in the
examples but any client works the same way.
## Step 1 — sanity check
In your LLM client, ask:
> Run the `health` and `axl_version` tools.
You should see:
```json
{
"cache": true,
"axl": true,
"docs": false,
"risport": true,
"axl_connection": "ok"
}
```
```json
{
"version": "15.0.1.12900-234",
"_cache": "miss"
}
```
If `docs` is `false` that's normal — it means `CISCO_DOCS_INDEX_PATH`
is unset (the docs indexer is optional). If `axl_connection` shows
`error`, re-check `AXL_URL` and credentials in your `.env`.
## Step 2 — invoke the route plan overview prompt
In Claude Code, type:
```
/mcp__cucm-axl__route_plan_overview
```
(The slash command name follows the pattern
`/mcp__<server-id>__<prompt-name>` — your `<server-id>` will match
whatever you used in `claude mcp add`.)
The prompt seeds the conversation with:
- A description of what to look for in the route plan
- A recommended sequence of tool calls (`route_partitions` ->
`route_calling_search_spaces` -> `route_patterns` -> follow-up
inspections on anything that looks anomalous)
- A findings template the LLM uses to structure observations
## Step 3 — let the LLM drive
The LLM will start by calling `route_partitions()` and
`route_calling_search_spaces()` to build a mental map of the cluster's
access-control structure. From there it will sample `route_patterns()`,
flag interesting transformations (calling-party masks, prefixes, digit
discards), and drill into specific patterns with `route_inspect_pattern`.
Don't interrupt unless you see a question — the prompt is designed to
orchestrate a multi-step audit autonomously. Typical run time on a
50-pattern cluster is 30-90 seconds.
## Step 4 — read the findings
The prompt asks the LLM to surface findings in a structured shape:
> **Finding 1 — Calling Search Space `Internal-Only` is unreferenced**
>
> Severity: Minor
> Evidence: `route_devices_using_css('Internal-Only')` returned 0 across all 71 known fkcallingsearchspace_* columns.
> Recommendation: Confirm intent; consider deletion to reduce config sprawl.
Findings are plain Markdown — copy them out, paste into a runbook,
or feed them to `whoami` / `route_inspect_pattern` for follow-up
verification.
## Step 5 — cross-reference with RisPort
If a finding mentions a phone, gateway, or trunk, follow up with:
```
Run device_registration_summary, then device_registration_status with
device_class=Phone for any model the summary flagged.
```
This pulls **live registration state** from RisPort70 — distinct from
the AXL configuration view. Compound findings like *"CSS X is
unreferenced AND zero phones currently registered against it"* require
both data sources.
## What to do next
- Try the `sip_trunk_report` prompt for a complete SIP trunk inventory
- Try the `whoami` prompt to verify your own service account's role chain
- Read the [SIP Trunk Report how-to](/how-to/sip-trunk-report/) for the deep version of that recipe
- Read [Hamilton review patterns](/explanation/hamilton-review-patterns/) to understand why the tool surface looks the way it does
## Troubleshooting
| Symptom | Likely cause |
|---|---|
| `axl_version` returns "WSDL not found" | `axlsqltoolkit.zip` not in cwd; see [WSDL bootstrap](/getting-started/installation/#wsdl-bootstrap) |
| `axl_connection: error` and `403 Forbidden` | Service account missing `Standard AXL Read Only API Access` role |
| `axl_connection: error` and `SSL: CERTIFICATE_VERIFY_FAILED` | Set `AXL_VERIFY_TLS=false` (CUCM self-signed cert) |
| Prompts return "no docs index configured" warnings | `CISCO_DOCS_INDEX_PATH` unset; this is fine, prompts still work |
| RisPort tools return rate-limit errors | Lower the page size; default `AXL_RATE_LIMIT_RETRIES=3` should auto-handle most 503s |

View File

@ -0,0 +1,99 @@
---
title: Installation
description: Install mcaxl from PyPI and wire it into your MCP-aware client (Claude Code, Continue, Cline).
sidebar:
order: 1
---
`mcaxl` is published on PyPI. The fastest path is `uvx`, which downloads
the package into a managed environment and runs the entry point in one
step — no virtualenv to manage, no editable install, no pinning to fight.
## Prerequisites
- **Python 3.11+** (3.12 / 3.13 work; `mcaxl` declares `requires-python = ">=3.11"`)
- **`uv`** — see [astral.sh/uv](https://docs.astral.sh/uv/) for install
- **Network reachability to CUCM** on port `8443/tcp` (AXL) and `8443/tcp` (RisPort70 — same port, different SOAP endpoint)
- **An AXL service account** on CUCM — see [Configuration](/getting-started/configuration/)
- **The Cisco AXL toolkit** — vendor-licensed, downloaded from your CUCM admin UI; see [WSDL bootstrap](#wsdl-bootstrap) below
## Install paths
### From PyPI via `uvx` (recommended)
```bash
uvx mcaxl
```
`uvx` resolves the package, creates an ephemeral virtualenv, and runs
`mcaxl` from the entry point declared in `pyproject.toml`. No state on
disk except `~/.cache/uv/` and `~/.cache/mcaxl/`. Re-running picks up
new releases automatically.
### Pinned dev install
```bash
pip install mcaxl==2026.04.27.1
mcaxl
```
Use this when you want to control upgrade timing — for example, in CI
or in a long-lived service account environment where reproducibility
matters more than auto-update.
### Via Claude Code's MCP registry
```bash
claude mcp add cucm-axl -- uvx mcaxl
```
This adds an entry to your `~/.claude/mcp.json` that Claude Code reads
on session start. The server runs as a child process of `claude`,
inherits your environment variables, and exits when Claude Code exits.
## WSDL bootstrap
CUCM's AXL toolkit is **Cisco-licensed and not redistributable**, so it
cannot be bundled with the package. Download it once from your CUCM admin UI:
> Application -> Plugins -> Find -> "Cisco AXL Toolkit" -> Download
Drop the resulting `axlsqltoolkit.zip` into your working directory. On
first launch, the server auto-extracts `schema/<version>/` (matching
your cluster) into `~/.cache/mcaxl/wsdl/<version>/`.
### Alternative resolution paths
```bash
# Explicit zip location (overrides cwd lookup)
export AXL_WSDL_ZIP=/path/to/axlsqltoolkit.zip
# Explicit WSDL file (skips zip extraction entirely)
export AXL_WSDL_PATH=/path/to/schema/15.0/AXLAPI.wsdl
# Or pre-populate the cache by hand
mkdir -p ~/.cache/mcaxl/wsdl/15.0/
cp /path/to/schema/15.0/* ~/.cache/mcaxl/wsdl/15.0/
```
Resolution order: `AXL_WSDL_PATH` -> `AXL_WSDL_ZIP` -> `./axlsqltoolkit.zip` -> `~/.cache/mcaxl/wsdl/<version>/`.
## Verify
After install, point an MCP client at the server and call the
`axl_version` tool. A successful round-trip looks like:
```json
{
"version": "15.0.1.12900-234",
"_cache": "miss"
}
```
If you get a connection error, see [Configuration](/getting-started/configuration/)
for the env-var checklist and TLS notes.
## Next
- [Configure your environment](/getting-started/configuration/) — env vars and the AXL service account role binding
- [Run your first audit](/getting-started/first-audit/) — walk the `route_plan_overview` prompt end to end

View File

@ -0,0 +1,144 @@
---
title: Find orphan resources
description: Identify CSSs, partitions, route lists, and route groups that are configured but unreferenced.
sidebar:
order: 4
---
CUCM clusters accumulate cruft over time. Test partitions from a
migration, CSSs cloned for a one-off project, route lists for a
decommissioned trunk — all sit in the database, all show up in the
admin UI, none are reachable from any actual call. Cleaning them up
reduces audit surface and removes the *"can I delete this?"*
ambiguity from future operators.
## The pattern
For any resource type, the question is the same:
> Is this resource referenced by anything that actually routes calls?
`mcaxl` doesn't have a single `find_orphans` tool because the answer
varies by resource type. Here are the recipes for each.
## Calling Search Spaces (CSS)
The canonical tool:
```
route_devices_using_css(css_name="<name>")
```
This walks **all 71 known fkcallingsearchspace_* columns** across the
schema (phones, gateways, trunks, translation patterns, route patterns,
hunt pilots, voicemail ports, MGCP endpoints, ...) and reports zero or
more references per category.
A CSS with zero references in every category is a confirmed orphan.
To enumerate CSSs and check each:
```
1. route_calling_search_spaces() -> list of all CSS names
2. For each CSS: route_devices_using_css(css_name=<name>)
3. Filter for CSSs where every category returned 0
```
A small LLM-driven loop handles this elegantly — ask the LLM to
*"find every CSS with no references across any device or pattern type
and report them as orphans."*
## Partitions
Partitions are referenced through CSSs (which include them) and
patterns (which live in them). A truly orphan partition has:
- Zero CSSs include it
- Zero patterns assigned to it
```sql
-- Orphan partitions: defined, never included in any CSS, contain no patterns
SELECT rp.name
FROM routepartition rp
WHERE NOT EXISTS (
SELECT 1 FROM callingsearchspacemember m
WHERE m.fkroutepartition = rp.pkid
)
AND NOT EXISTS (
SELECT 1 FROM numplan n WHERE n.fkroutepartition = rp.pkid
);
```
Partitions that are in a CSS but contain no patterns are a softer
finding — they're not strictly orphan (CSSs reference them) but
they don't *do* anything.
## Route lists
Route lists are referenced by route patterns (`numplan.fkroutelist`)
and a few specialized features (Hunt Pilot, etc.).
```sql
-- Orphan route lists: defined, never targeted by any pattern
SELECT rl.name
FROM routelist rl
WHERE NOT EXISTS (
SELECT 1 FROM numplan n WHERE n.fkroutelist = rl.pkid
);
```
## Route groups
Route groups are referenced by route lists (via the route-list
membership table) and by device-pool Local Route Group mappings.
```sql
-- Orphan route groups: not in any route list, not any pool's local RG
SELECT rg.name
FROM routegroup rg
WHERE NOT EXISTS (
SELECT 1 FROM routelistmemberlist m WHERE m.fkroutegroup = rg.pkid
)
AND NOT EXISTS (
SELECT 1 FROM devicepool dp WHERE dp.fkroutegroup_local = rg.pkid
);
```
(The exact join-table name has shifted across CUCM versions —
`route_lists_and_groups()` is the version-aware abstraction. Confirm
the table with `axl_list_tables('route%')` if the SQL above errors.)
## Phones
A phone configured in CUCM but never registered is a different kind of
orphan — and AXL alone can't tell you (the database doesn't track
registration history). Cross-reference with RisPort70:
```
1. axl_sql("SELECT name, description FROM device WHERE tkclass = (SELECT enum FROM typeclass WHERE name = 'Phone')")
2. device_registration_status(device_class="Phone", status="any")
3. Set-difference: phones in (1) but not in (2)
```
Phones that haven't registered in the lifetime of the current
unrestarted CUCM are confirmed orphans. Phones that *just* haven't
registered today might just be powered off — be cautious about acting
on a single snapshot.
## Wrap it in a prompt
The `audit_routing(focus="full")` prompt asks the LLM to surface orphan
resources as part of its findings. For a more targeted run, ask:
> Walk the cluster's CSSs, partitions, route lists, and route groups.
> Report any that have zero references. For each, state the evidence
> (which tool returned what) and a confidence level.
The LLM handles the loops; you focus on which findings to actually act
on.
## Related
- [`route_devices_using_css`](/reference/tools/#route_devices_using_css) — the 71-column impact analysis
- [`audit_routing` prompt](/reference/prompts/#audit_routing) — surfaces orphans as part of a broader audit
- [Hamilton review patterns](/explanation/hamilton-review-patterns/) — why the schema-coverage test guards the 71-column list against drift

View File

@ -0,0 +1,94 @@
---
title: Investigate a specific pattern
description: Walk a single route pattern from match through transformations to destination.
sidebar:
order: 3
---
When you need to answer *"what happens when someone dials `9.@`?"*
or a translation pattern is misbehaving and you need to see every
transformation step — `route_inspect_pattern` is the canonical tool.
## When to use it
- A user reports a call that routed unexpectedly and you have the dialed digits
- You're about to delete a pattern and want to know the full blast radius
- You're debugging a calling-party-number transformation
- A finding from `route_plan_overview` needs deeper inspection
## Invocation
Direct tool call:
```
route_inspect_pattern("9.@", partition="PSTN-PT")
```
Or via the prompt for a guided narrative:
```
/mcp__cucm-axl__investigate_pattern pattern="9.@" partition="PSTN-PT"
```
The `partition` argument is optional but recommended — without it, the
tool searches all partitions and may return multiple rows if the same
pattern exists in different partitions.
## What it returns
A single dictionary with these keys:
| Key | Contents |
|---|---|
| `pattern` | The pattern row itself: all transformation masks, prefix digits, DDI assignment |
| `reverse_css_lookup` | Every CSS that includes this pattern's partition (in what order) |
| `route_filter` | If the pattern uses `@` and has a route filter, the filter clauses + member rules |
| `destination_chain` | If it's a route pattern: route list -> route group(s) -> gateway/trunk leaves |
The destination chain is the most operationally useful field — it's the
full call-routing topology that this one pattern reaches.
## Reading the output
```mermaid
flowchart LR
Pattern["9.@<br/>Partition: PSTN-PT"]
RL["Route List:<br/>RL_PSTN_Primary"]
RG1["Route Group:<br/>RG_PSTN_SiteA"]
RG2["Route Group:<br/>RG_PSTN_SiteB"]
Trunk1["SIP Trunk:<br/>Carrier-SiteA"]
Trunk2["SIP Trunk:<br/>Carrier-SiteB"]
Pattern --> RL
RL -->|priority 1| RG1
RL -->|priority 2| RG2
RG1 --> Trunk1
RG2 --> Trunk2
```
The chain stops at the leaf devices (gateways, SIP trunks). To go
*further* — into the actual carrier-side configuration — use the
`sipdevice` and `siptrunkdestination` queries from the
[SIP trunk report](/how-to/sip-trunk-report/) recipe.
## Common gotchas
- **`@` patterns** require route filters to be operationally useful, but
`route_inspect_pattern` does not yet model filter constraints when
building the destination chain. Use the `route_filter` field in the
output to check filter rules manually.
- **Local Route Groups** (route groups with no static device members)
resolve at call-time via the calling device's device-pool
`fkroutegroup_local` mapping. The destination chain shows them as
`local_route_group: true` with no static gateway list — follow up
with `route_device_pool_route_groups()` to enumerate per-pool resolution.
- **Translation patterns** (vs route patterns) don't have a destination
chain — they rewrite digits and re-enter digit analysis with the new
number. The output's `destination_chain` will be empty; look at the
transformation masks instead.
## Related
- [`route_inspect_pattern` tool](/reference/tools/#route_inspect_pattern)
- [`investigate_pattern` prompt](/reference/prompts/#investigate_pattern)
- [`route_translation_chain`](/reference/tools/#route_translation_chain) — wildcard-aware matcher for "what does dialing X do?" questions

View File

@ -0,0 +1,85 @@
---
title: Generate a route plan overview
description: Use the route_plan_overview prompt to seed a structured audit of the cluster dial plan.
sidebar:
order: 2
---
The `route_plan_overview` prompt is the fastest path to a structured
audit of a CUCM dial plan. It's the right starting point for any of:
- A handoff document for a new operator
- A post-migration sanity check
- A pre-change impact assessment
- Just *understanding* an unfamiliar cluster
## When to use it
Use the prompt **at the start** of a fresh LLM session — it seeds the
conversation context with:
- A description of CUCM's dial-plan model (partitions, CSSs, patterns, transformations)
- A recommended tool-call sequence
- A findings template for structured output
Don't use it mid-session if the LLM already has plenty of cluster
context loaded — duplicating the orientation material wastes tokens.
## Invocation
In Claude Code:
```
/mcp__cucm-axl__route_plan_overview
```
In a generic MCP client, look for a "Prompts" menu and select
`route_plan_overview` (no arguments).
## What happens
The LLM will follow the prompt's recommended sequence:
1. **`route_partitions()`** — top-down map of access-control groupings
2. **`route_calling_search_spaces()`** — the CSSs that bind partitions into
ordered search lists
3. **`route_patterns(kind="route")`** then `kind="translation"` — the patterns
that actually route calls
4. **Sampling** — for any pattern that looks anomalous, follow up with
`route_inspect_pattern(pattern, partition)`
Total run time on a 50-pattern cluster: 30-90 seconds.
## What to expect in the output
Findings appear in a structured shape:
```markdown
**Finding N — <short title>**
Severity: Critical | Major | Minor
Evidence: <which tool calls returned what>
Recommendation: <what to do about it>
```
Common findings on a long-lived cluster:
- Unreferenced CSSs (configured but not bound to any device)
- Unreferenced partitions (configured but not in any CSS)
- Translation patterns that loop or shadow each other
- Route patterns with no destination (route list deleted but pattern remains)
- Wildcard collisions (two patterns with overlapping match space)
## Follow-up workflows
Once the overview is done, drill in with one of these recipes:
- [Investigate a specific pattern](/how-to/investigate-pattern/)
- [Find orphan resources](/how-to/find-orphan-resources/)
- [SIP trunk report](/how-to/sip-trunk-report/)
## Related
- [`route_plan_overview` prompt](/reference/prompts/#route_plan_overview)
- [`audit_routing` prompt](/reference/prompts/#audit_routing) — heavier-weight version with a checklist
- [CUCM schema cheat-sheet](/reference/cucm-schema-cheatsheet/)

View File

@ -0,0 +1,210 @@
---
title: SIP trunk report
description: Produce a complete SIP trunk inventory with destinations, profiles, and downstream route-group membership.
sidebar:
order: 1
---
**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.
---
## 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, URI 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.example.com`) 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
often 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 the `sip_trunk_report` prompt runs, it asks 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).
---
## Related
- [`route_lists_and_groups`](/reference/tools/#route_lists_and_groups) — the right way to traverse the trunk -> RG -> RL chain
- [`route_devices_using_css`](/reference/tools/#route_devices_using_css) — for follow-up blast-radius analysis on each trunk's CSS
- [`sip_trunk_report` prompt](/reference/prompts/#sip_trunk_report) — invokes the queries above with the findings template wired in

View File

@ -0,0 +1,77 @@
---
title: mcaxl
description: Read-only MCP server for Cisco Unified Communications Manager — AXL SOAP API + RisPort70 audit. Documentation home.
template: splash
hero:
tagline: Read-only audit of CUCM dial plan and configuration via the AXL SOAP API and RisPort70 — wired to your LLM through MCP.
actions:
- text: Install in 60 seconds
link: /getting-started/installation/
icon: right-arrow
variant: primary
- text: Tool reference
link: /reference/tools/
icon: external
variant: secondary
---
import CardGrid from '../../components/CardGrid.astro';
import Diataxis from '../../components/Diataxis.astro';
## What it does
`mcaxl` is a Python MCP server that exposes Cisco Unified Communications
Manager configuration to LLM-aware clients. It's read-only by structure
(no AXL write methods are ever registered) and ships 19 tools plus 10
audit-narrative prompts that orchestrate those tools toward findings.
It pairs well with [`@calltelemetry/cisco-cucm-mcp`](https://github.com/calltelemetry/cisco-cucm-mcp)
for operational debugging — `mcaxl` answers *"what does the config say?"*,
that server answers *"what's happening right now?"*.
<CardGrid
cards={[
{
title: 'Install',
description: 'uvx mcaxl, an axlsqltoolkit.zip, and an .env. That is the whole bootstrap.',
href: '/getting-started/installation/',
icon: 'download',
},
{
title: 'First audit',
description: 'Walk through the route_plan_overview prompt end to end on a live cluster.',
href: '/getting-started/first-audit/',
icon: 'compass',
},
{
title: 'SIP trunk report',
description: 'A complete trunk inventory recipe — queries, joins, gotchas, follow-ups.',
href: '/how-to/sip-trunk-report/',
icon: 'cable',
},
{
title: 'Tool reference',
description: 'All 19 tools with arguments, return shape, and which audits they feed.',
href: '/reference/tools/',
icon: 'wrench',
},
]}
/>
## Find your way around
This site follows the [Diátaxis](https://diataxis.fr/) framework — every
page is one of four kinds, and the sidebar groups them accordingly.
<Diataxis />
## Project status
| Field | Value |
|---|---|
| Latest version | `2026.04.27.1` (CalVer) |
| Tested against | CUCM 15.0(1) |
| Compatible with | CUCM 12.5+ |
| License | MIT |
| PyPI | [pypi.org/project/mcaxl](https://pypi.org/project/mcaxl/) |
| Source | [git.supported.systems/mcp/mcaxl](https://git.supported.systems/mcp/mcaxl) |

View File

@ -0,0 +1,194 @@
---
title: CUCM schema cheat-sheet
description: The Informix tables and joins you'll actually use day-to-day, with the gotchas that bit us.
sidebar:
order: 4
---
CUCM's Informix schema has hundreds of tables. This cheat-sheet
covers the ones that actually come up in routine audits, with
the join shapes and gotchas accumulated from real cluster work.
For exhaustive reference, query the `cisco-docs` MCP server's
`search_docs("data dictionary <table>")` or browse Cisco's published
data dictionary PDF.
## Naming conventions
| Prefix | Meaning |
|---|---|
| `tk*` | "Type Key" — enum FK column on a row, joins to `type*` lookup |
| `type*` | Enum lookup table (e.g. `typeclass`, `typesipcodec`, `typepatternusage`) |
| `fk*` | Foreign key to another table's `pkid` |
| `pkid` | Primary key (UUID, stored as VARCHAR) |
Most schema confusion comes from mistaking a `tk*` (enum column on the
fact row) for an `fk*` (FK to another fact row).
## Identity
### `device`
The fact table for every "device" in CUCM: phones, gateways, trunks,
hunt lists, MOH, conference bridges. The `tkclass` column is the
discriminator.
| Column | Type | Notes |
|---|---|---|
| `pkid` | UUID | PK |
| `name` | VARCHAR | Device name (typically MAC for phones, hostname for gateways) |
| `description` | VARCHAR | Free-form operator annotation |
| `tkclass` | INT | enum -> `typeclass` |
| `fkdevicepool` | UUID | -> `devicepool` |
| `fkcallingsearchspace` | UUID | -> `callingsearchspace` |
| `fklocation` | UUID | -> `location` |
| `fksipprofile` | UUID | -> `sipprofile` (SIP devices only) |
| `fksecurityprofile` | UUID | -> `phonesecurityprofile` for phones, `sipsecurityprofile` for SIP trunks |
**Gotcha:** there's no single `securityprofile` table — the FK target
varies by `tkclass`. SIP trunks use `sipsecurityprofile`; phones use
`phonesecurityprofile`.
### `typeclass`
Discriminator lookup for `device.tkclass`.
```sql
SELECT enum, name FROM typeclass ORDER BY enum;
```
Common values: `Phone`, `Gateway`, `H323 Gateway`, `H323 Phone`, `Trunk`,
`CTI Route Point`, `CTI Port`, `Conference Bridge`, `Music On Hold`,
`Hunt List`, `Voice Mail`.
## Dial plan
### `routepartition`
Partitions group patterns for access control.
| Column | Notes |
|---|---|
| `pkid` | PK |
| `name` | Partition name |
| `description` | Free-form |
### `callingsearchspace`
Calling Search Spaces (CSSs).
| Column | Notes |
|---|---|
| `pkid` | PK |
| `name` | CSS name |
CSS membership lives in `callingsearchspacemember`:
```sql
-- CSSs and their ordered partition lists
SELECT
css.name AS css_name,
rp.name AS partition_name,
m.sortorder
FROM callingsearchspace css
JOIN callingsearchspacemember m ON m.fkcallingsearchspace = css.pkid
JOIN routepartition rp ON m.fkroutepartition = rp.pkid
ORDER BY css.name, m.sortorder;
```
### `numplan`
The "everything pattern" table — DNs, route patterns, translations,
hunt pilots, voicemail pilots, conference, park codes — they all live
here, discriminated by `tkpatternusage`.
| Column | Notes |
|---|---|
| `dnorpattern` | The pattern text |
| `fkroutepartition` | -> `routepartition` |
| `tkpatternusage` | enum -> `typepatternusage` |
| `prefixdigitsout` | Outbound prefix |
| `calledpartytransformationmask` | Called party transform |
| `callingpartytransformationmask` | Calling party transform |
| `tkdigitdiscardinst` | enum -> `digitdiscardinstruction` |
| `fkroutelist` | -> `routelist` (route patterns) |
### `routelist` / `routegroup`
Route lists are ordered groups of route groups. Route groups are
ordered groups of devices (gateways, trunks).
**Don't write the join SQL by hand** — the membership table name has
shifted across CUCM versions. Use the `route_lists_and_groups()` MCP
tool which handles version detection.
## Users and roles
### `applicationuser`
Application user accounts (service accounts, including the AXL one).
### `enduser`
End-user accounts (associated with phones, mailboxes).
### Role chain
The four-table join that the `whoami` prompt walks:
```sql
SELECT
au.userid,
dg.name AS access_control_group,
fr.name AS function_role
FROM applicationuser au
JOIN applicationuserdirgroupmap m ON m.fkapplicationuser = au.pkid
JOIN dirgroup dg ON m.fkdirgroup = dg.pkid
JOIN functionroledirgroupmap fdm ON fdm.fkdirgroup = dg.pkid
JOIN functionrole fr ON fdm.fkfunctionrole = fr.pkid
WHERE au.userid = 'mcaxl';
```
For end users, swap `applicationuser` -> `enduser` and
`applicationuserdirgroupmap` -> `enduserdirgroupmap`.
## SIP trunks
See [How-to: SIP trunk report](/how-to/sip-trunk-report/) for the full
recipe. Key tables:
- `sipdevice` — SIP-specific config (joined via `fkdevice -> device.pkid`)
- `siptrunkdestination` — destination IP/port (one-to-many)
- `sipprofile`, `sipsecurityprofile` — name lookups
- `typesipcodec` — codec name enum
## LVARCHAR(1) flag fields
Many "boolean" columns in CUCM are actually `LVARCHAR(1)` returning
`'t'` or `'f'` (lowercase, single character) — not Python booleans. If
you `SELECT` one through `axl_sql`, you'll get the string. Render
appropriately in any output.
Examples: `sipdevice.isanonymous`, `sipdevice.acceptinboundrdnis`,
`device.allowctianagement`, `enduser.enableupnauthentication`.
## Common gotchas
1. **`routelistdetail` doesn't exist** — the route-list-to-route-group
join table has a version-dependent name. Use `route_lists_and_groups()`.
2. **`securityprofile` is a category, not a table** — SIP trunks use
`sipsecurityprofile`, phones use `phonesecurityprofile`.
3. **`tkclass` filters by enum, not string** — always join to
`typeclass` for the human-readable label.
4. **NULL FKs are valid** — Expressway-C trunks legitimately have
`fkcallingsearchspace = NULL`. Use `LEFT JOIN` and render NULL
appropriately.
5. **Pattern partitions can be NULL** — patterns in the (no partition)
bucket have `fkroutepartition = NULL`. The default-CSS rules apply
to them.
## See also
- [Tools reference](/reference/tools/) — the helpers that abstract over the gotchas above
- [SIP trunk report](/how-to/sip-trunk-report/) — worked example with all the joins
- Cisco's official CUCM data dictionary — search via `cisco-docs` MCP for "data dictionary"

View File

@ -0,0 +1,167 @@
---
title: Environment variables
description: Every environment variable mcaxl reads, with defaults and edge cases.
sidebar:
order: 3
---
`mcaxl` reads its configuration from environment variables. A `.env`
file in the working directory is loaded automatically (via
`python-dotenv`); anything in the actual process environment overrides
values from `.env`.
## Connection
### `AXL_URL`
**Required.** Full URL to the AXL endpoint on the CUCM publisher.
```env
AXL_URL=https://cucm-pub.example.com:8443/axl/
```
- Must point to the **publisher** node (AXL doesn't run on subscribers).
- Must end in `/axl/` (trailing slash).
- Default port `8443/tcp`.
### `AXL_USER`
**Required.** Application User account with `Standard AXL Read Only API
Access` role at minimum.
```env
AXL_USER=mcaxl
```
### `AXL_PASS`
**Required.** Password for `AXL_USER`. Quote if it contains shell
metacharacters.
```env
AXL_PASS=change-me
```
### `AXL_VERIFY_TLS`
Default: `false`.
CUCM ships self-signed certs; most clusters never replace them. Set
`true` if your cluster has a CA-signed cert that's trusted by the OS
trust store on the host running `mcaxl`.
```env
AXL_VERIFY_TLS=false
```
There is no `AXL_CA_BUNDLE` knob yet — install your private CA into the
OS trust store if you need to validate against one.
## Caching
### `AXL_CACHE_TTL`
Default: `3600` (1 hour). Units: seconds.
Set to `0` to disable caching entirely. Most operators leave the
default; CUCM config doesn't change second-to-second, and 1 hour
matches the typical audit-session duration.
```env
AXL_CACHE_TTL=3600
```
## Resilience
### `AXL_RATE_LIMIT_RETRIES`
Default: `3`. Integer count.
When CUCM returns `502 / 503 / 504`, `mcaxl` retries with exponential
backoff. RisPort70 in particular can throttle aggressively under load;
the default handles most cases. Bump to `5` or `6` on a cluster with
chronic load issues.
```env
AXL_RATE_LIMIT_RETRIES=3
```
## WSDL resolution
### `AXL_WSDL_PATH`
Default: empty (unset).
Explicit path to an extracted `AXLAPI.wsdl`. Highest priority — if set,
no zip extraction is attempted.
```env
AXL_WSDL_PATH=/path/to/schema/15.0/AXLAPI.wsdl
```
### `AXL_WSDL_ZIP`
Default: empty (unset).
Explicit path to `axlsqltoolkit.zip`. Used if `AXL_WSDL_PATH` is unset.
```env
AXL_WSDL_ZIP=/path/to/axlsqltoolkit.zip
```
Resolution order: `AXL_WSDL_PATH` -> `AXL_WSDL_ZIP` -> `./axlsqltoolkit.zip` -> `~/.cache/mcaxl/wsdl/<version>/`.
## Prompt enrichment
### `CISCO_DOCS_INDEX_PATH`
Default: empty (unset).
Path to a directory containing `chunks.jsonl` and `index_meta.json`
produced by the `mcp-cisco-docs` indexer (or any compatible embedding
pipeline). When set, prompts pull relevant Cisco documentation chunks
inline. When unset, prompts gracefully degrade to a fallback notice
asking the LLM to use a sibling `cisco-docs` server's `search_docs`
tool.
```env
CISCO_DOCS_INDEX_PATH=/var/lib/cisco-docs-index/15.0/
```
## Verification
After editing your `.env`, restart the MCP server (`claude mcp restart
cucm-axl` in Claude Code) and call the `health` tool. Every key should
be `true` except `docs` if you haven't configured `CISCO_DOCS_INDEX_PATH`.
```json
{
"cache": true,
"axl": true,
"docs": false,
"risport": true,
"axl_connection": "ok",
"cache_cluster_id": "<sha256-hex-prefix>"
}
```
## Sample full `.env`
```env
# Connection
AXL_URL=https://cucm-pub.example.com:8443/axl/
AXL_USER=mcaxl
AXL_PASS=change-me
# TLS
AXL_VERIFY_TLS=false
# Caching
AXL_CACHE_TTL=3600
# Resilience
AXL_RATE_LIMIT_RETRIES=3
# Optional schema-doc enrichment for prompts
CISCO_DOCS_INDEX_PATH=/var/lib/cisco-docs-index/15.0/
```

View File

@ -0,0 +1,145 @@
---
title: Prompts (10)
description: Audit-narrative prompts that orchestrate multiple tool calls toward findings.
sidebar:
order: 2
---
Each prompt orchestrates multiple tool calls toward a specific audit
narrative. Prompts are **schema-grounded**: when `CISCO_DOCS_INDEX_PATH`
is configured, they pull relevant Cisco documentation chunks inline so
the LLM has authoritative reference material at hand. Without the index,
they degrade gracefully with a fallback notice.
In Claude Code they appear under the slash menu as
`/mcp__<server-id>__<prompt-name>`.
## `route_plan_overview`
Fresh-conversation seed for a structured dial-plan audit.
**Arguments:** none
**Use when:** you're starting an audit on an unfamiliar cluster, or
preparing a handoff document.
**See also:** [How-to: Generate a route plan overview](/how-to/route-plan-overview/)
## `investigate_pattern`
Single-pattern deep dive — match logic, transformations, destination
chain, blast radius.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `pattern` | `str` | The pattern text (e.g. `"9.@"`). |
| `partition` | `str \| None` | Recommended — disambiguates same pattern in different partitions. |
**See also:** [How-to: Investigate a specific pattern](/how-to/investigate-pattern/)
## `audit_routing`
Comprehensive walkthrough with checklist. Heavier than
`route_plan_overview` — this one walks every category and asks the LLM
to populate a checklist of common findings.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `focus` | `str` | `full` (default), `partitions`, `css`, `patterns`, `transformations`, `filters`. |
## `cucm_sql_help`
Catch-all SQL helper. Pass a natural-language question; the prompt
seeds context about the Informix data dictionary, common joins, and
LVARCHAR(1) flag fields, then asks the LLM to produce and execute the
SQL.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `question` | `str` | Natural-language question. |
## `sip_trunk_report`
SIP trunk inventory + findings template. Embeds the Query 1 + Query 2
SQL plus the recommended follow-up sequence.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `name_filter` | `str \| None` | Optional substring match against trunk name. |
**See also:** [How-to: SIP trunk report](/how-to/sip-trunk-report/)
## `phone_inventory_report`
Phone fleet aggregates with anomaly findings. **Cross-references
RisPort70** for registration state, so findings can compound (e.g.
*"this phone model is configured but zero of them are currently
registered"*).
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `filter` | `str \| None` | Optional substring against phone name. |
## `user_audit`
End users + application users + role assignments. Surfaces:
- Application users with write-capable roles
- End users who haven't logged in recently (where the cluster tracks this)
- Role assignments that look misnamed ("Read-Only-X" group containing write-capable roles — the case that started this whole project; see [whoami example](/) on the home page)
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `focus` | `str` | `full` (default), `app_users`, `end_users`, `roles`. |
## `inbound_did_audit`
XFORM-Inbound-DNIS inventory + screening pipeline. Walks every inbound
translation pattern and reports the digit-manipulation chain from
PSTN-received DNIS to internal extension.
**Arguments:** none
## `hunt_pilot_audit`
Hunt pilots, queue settings, line group membership. Surfaces:
- Hunt pilots with empty line groups
- Line groups with all members offline (cross-references RisPort)
- Queue depth / overflow misconfiguration
**Arguments:** none
## `whoami`
Single-user role chain. Walks the four-table join `applicationuser ->
applicationuserdirgroupmap -> dirgroup -> functionroledirgroupmap ->
functionrole` and produces an audit-style finding.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `userid` | `str \| None` | If omitted, defaults to the AXL service account from `AXL_USER`. |
This is the prompt featured on the home page — runs against the
configured service account by default and surfaces *"this group's name
implies read-only intent but it has write-capable roles"* as a finding
without any operator effort.
## See also
- [Tools](/reference/tools/) — the underlying tool surface each prompt orchestrates
- [Environment variables](/reference/env-vars/) — `CISCO_DOCS_INDEX_PATH` for prompt enrichment

View File

@ -0,0 +1,254 @@
---
title: Tools (19)
description: Every MCP tool mcaxl exposes — arguments, return shape, caching behavior.
sidebar:
order: 1
---
`mcaxl` registers 19 tools across three groups: **foundational**
(version, raw SQL, table introspection, cache, health), **route plan**
(partitions, CSSs, patterns, lists, groups, transformations, filters),
and **real-time registration** (RisPort70 device state).
Every tool is read-only. The server never registers AXL write methods —
no `executeSQLUpdate`, no `add*` / `update*` / `remove*` / `apply*` /
`reset*` / `restart*`. See [Read-only by structure](/explanation/read-only-by-structure/)
for the rationale.
## Foundational
### `axl_version`
Cluster version sanity check. Cached for 1 hour — version doesn't
change between cluster upgrades.
**Arguments:** none
**Returns:** `{ version: str, _cache: "hit" | "miss" }`
### `axl_sql`
Execute a `SELECT` (or `WITH` CTE) against the CUCM Informix data
dictionary. Read-only by structural guarantee plus client-side
validation that rejects non-`SELECT`/`WITH` queries as
defense-in-depth.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `query` | `str` | A single SQL SELECT statement. Trailing semicolon optional. |
**Returns:** `{ row_count: int, rows: list[dict], query_sent: str, _cache: "hit" | "miss" }`
### `axl_list_tables`
List Informix tables in the CUCM database.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `pattern` | `str \| None` | Optional LIKE pattern (`%` wildcards). e.g. `"route%"` finds `routelist`, `routegroup`, `routepartition`, `routefilter`. |
### `axl_describe_table`
Describe an Informix table's columns: name, type, length, nullability.
Use this **before** writing `axl_sql` queries against an unfamiliar
table.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `table_name` | `str` | Exact Informix table name. |
### `cache_stats`
Cache statistics: total entries, live entries, breakdown by method.
**Arguments:** none
### `cache_clear`
Clear cache entries for the current cluster.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `method_pattern` | `str \| None` | Optional method-name pattern (`%` wildcards). If omitted, clears the entire cache. |
### `health`
Server-state self-check: which globals are initialized, AXL connection
state from the most recent attempt.
**Returns:** `{ cache: bool, axl: bool, docs: bool, risport: bool, axl_connection: str, cache_cluster_id: str }`
## Route plan
### `route_partitions`
All route partitions, with pattern count and CSS member count per
partition.
**Arguments:** none
### `route_calling_search_spaces`
Calling Search Spaces with their ordered partition lists.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `name` | `str \| None` | Optional CSS name to fetch one specific CSS. If `None`, returns all. |
### `route_patterns`
The Route Plan Report — patterns with their transformations.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `kind` | `str \| None` | Pattern kind filter. One of: `directory_number`, `translation`, `route`, `conference`, `voicemail`, `hunt_pilot`, `call_pickup_group`, `park_code`, `directed_pickup`, `message_waiting`, `device_template`. Default: all kinds. |
| `partition` | `str \| None` | Filter by partition name (exact match). |
| `filter` | `str \| None` | Substring match against the pattern text. |
| `limit` | `int` | Max rows. Default 500. |
### `route_inspect_pattern`
Deep dive on a single pattern. Returns the pattern row, the route
filter (if any), every CSS that includes the pattern's partition, and
the full destination chain (route list -> route groups -> gateways).
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `pattern` | `str` | The pattern text (e.g. `"9.@"`, `"\\+1[2-9]XX[2-9]XXXXXX"`). |
| `partition` | `str \| None` | Optional partition name. Recommended — disambiguates same pattern in different partitions. |
### `route_lists_and_groups`
Route list -> route group -> gateway/trunk chain. The right way to
traverse the routing topology — handles version-shifting join-table
names internally.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `name` | `str \| None` | Optional route list name. If `None`, returns all. |
### `route_translation_chain`
Wildcard-aware pattern matcher. Given a number and an optional CSS,
returns the chain of patterns that would match.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `number` | `str` | The dialed digits. |
| `css_name` | `str \| None` | Optional CSS context. If `None`, searches all partitions. |
**Caveat:** Evaluates CUCM wildcards (`X`, `!`, `[0-9]`, `@`, `\+`) but
does *not* model route-filter constraints on `@` patterns. Use as
guidance, not authoritative.
### `route_digit_discard_instructions`
Catalog of all Digit Discard Instructions (DDIs) defined on the
cluster.
**Arguments:** none
### `route_device_pool_route_groups`
Local Route Group resolution per device pool. For pools with a
`fkroutegroup_local` mapping, returns the route group that the pool's
calling devices resolve "Standard Local Route Group" to.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `device_pool_name` | `str \| None` | Optional pool name filter. |
### `route_devices_using_css`
Impact analysis: which devices, patterns, and templates reference the
given CSS. Walks all 71 known `fkcallingsearchspace_*` columns across
the schema.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `css_name` | `str` | Exact CSS name. |
| `max_per_category` | `int` | Limit per category (default 50). |
The 71-column coverage is regression-tested by
`test_complete_schema_coverage_against_known_columns` — adding a new
CUCM version with new CSS-bearing columns will fail red until the tool
is updated. See [Hamilton review patterns](/explanation/hamilton-review-patterns/).
### `route_filters`
Route filter clauses + member rules. Route filters constrain `@`
patterns to a subset of the international dial plan.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `name` | `str \| None` | Optional filter name. |
| `include_members` | `bool` | If `True`, include member rules. Default `False`. |
## Real-time registration (RisPort70)
These tools query CUCM's RisPort70 SOAP service rather than AXL — they
return **live registration state**, not configuration. They share the
same auth credentials as AXL.
### `device_registration_status`
Page through CUCM's RisPort `selectCmDevice` for live registration
state.
**Arguments:**
| Name | Type | Description |
|---|---|---|
| `device_class` | `str` | One of `Phone`, `Gateway`, `H323`, `Cti`, `VoiceMail`, `MediaResources`, `HuntList`, `SIPTrunk`, `Any`. |
| `status` | `str` | `Registered`, `UnRegistered`, `Rejected`, `PartiallyRegistered`, `Unknown`, `Any`. |
| `name_filter` | `str` | Substring match against device name. |
| `page_size` | `int` | Default 200. RisPort caps at 1000. |
### `device_registration_summary`
Cluster-wide breakdown across `Phone`, `Gateway`, `SIPTrunk`,
`HuntList`, etc. — registered / unregistered / rejected counts per
class.
**Arguments:** none
## Caching behavior
Every tool's response is cached in SQLite at
`~/.cache/mcaxl/responses/axl_responses.sqlite`. Cache is
**cluster-isolated** by SHA-256 of `AXL_URL` — pointing at a different
cluster never serves stale data from a previous one. See
[Cluster-isolated cache](/explanation/cluster-isolated-cache/).
Default TTL: 1 hour (`AXL_CACHE_TTL=3600`). Set to `0` to disable.
Force-refresh a specific method with `cache_clear(method_pattern="route_patterns")`.
## See also
- [Prompts](/reference/prompts/) — orchestrated audit narratives that call these tools
- [Environment variables](/reference/env-vars/)
- [CUCM schema cheat-sheet](/reference/cucm-schema-cheatsheet/)

1
docs/src/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

165
docs/src/styles/custom.css Normal file
View File

@ -0,0 +1,165 @@
/*
* mcaxl docs theme overrides
*
* Palette: muted forest-green accent, warm slate neutrals.
* No purple, no aggressive gradients. Both light and dark modes
* get legibility-first treatment.
*
* Swatches:
* green-50 #eef5f1
* green-100 #d4e7dc
* green-200 #a8cdb8
* green-400 #4f9a78
* green-500 #2f7d5e (primary accent dark mode)
* green-600 #1f6f5c
* green-700 #185647
* green-800 #103e34
* green-900 #0a2620
*/
:root {
--sl-color-accent-low: #103e34;
--sl-color-accent: #2f7d5e;
--sl-color-accent-high: #a8cdb8;
/* Warm neutral grey scale (dark mode; Starlight inverts for light) */
--sl-color-white: #f3efe6;
--sl-color-gray-1: #e1ddd4;
--sl-color-gray-2: #b6b1a7;
--sl-color-gray-3: #888278;
--sl-color-gray-4: #555049;
--sl-color-gray-5: #38352f;
--sl-color-gray-6: #28251f;
--sl-color-black: #1a1814;
}
:root[data-theme='light'] {
--sl-color-accent-low: #d4e7dc;
--sl-color-accent: #1f6f5c;
--sl-color-accent-high: #0a2620;
--sl-color-white: #1a1814;
--sl-color-gray-1: #28251f;
--sl-color-gray-2: #38352f;
--sl-color-gray-3: #555049;
--sl-color-gray-4: #888278;
--sl-color-gray-5: #d2cec4;
--sl-color-gray-6: #ebe7dd;
--sl-color-gray-7: #f3efe6;
--sl-color-black: #fbf8f0;
}
/* Slightly tighter line height for code-heavy pages */
html {
font-feature-settings: 'ss01', 'cv11';
}
/* Card grid for the landing page */
.mc-card-grid {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
margin-block: 1.5rem;
}
@media (min-width: 640px) {
.mc-card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 960px) {
.mc-card-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.mc-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.25rem;
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.625rem;
background: var(--sl-color-black);
text-decoration: none;
color: inherit;
transition:
border-color 150ms ease,
transform 150ms ease;
}
.mc-card:hover {
border-color: var(--sl-color-accent);
transform: translateY(-1px);
}
.mc-card h3 {
margin: 0;
font-size: 1.05rem;
color: var(--sl-color-white);
}
.mc-card p {
margin: 0;
font-size: 0.9rem;
color: var(--sl-color-gray-2);
line-height: 1.45;
}
.mc-card-icon {
color: var(--sl-color-accent);
width: 1.5rem;
height: 1.5rem;
}
/* Diátaxis "what kind of page is this?" badge row on the home page */
.mc-diataxis {
display: grid;
gap: 0.75rem;
grid-template-columns: 1fr;
margin-block: 1.25rem;
}
@media (min-width: 720px) {
.mc-diataxis {
grid-template-columns: repeat(2, 1fr);
}
}
.mc-diataxis-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid var(--sl-color-gray-5);
border-left: 3px solid var(--sl-color-accent);
border-radius: 0.4rem;
}
.mc-diataxis-row strong {
color: var(--sl-color-white);
}
.mc-diataxis-row p {
margin: 0.15rem 0 0;
font-size: 0.875rem;
color: var(--sl-color-gray-2);
line-height: 1.45;
}
/* Inline pills for the tool/prompt reference tables */
.mc-pill {
display: inline-block;
padding: 0.05rem 0.45rem;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
border: 1px solid currentColor;
vertical-align: middle;
}
.mc-pill.read-only { color: #4f9a78; }
.mc-pill.cached { color: #b28943; }
.mc-pill.realtime { color: #6691b8; }

5
docs/tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}