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:
parent
0691ba8c46
commit
f060170e90
8
docs/.gitignore
vendored
Normal file
8
docs/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# Astro / build output
|
||||
.astro/
|
||||
dist/
|
||||
node_modules/
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
*.log
|
||||
133
docs/astro.config.mjs
Normal file
133
docs/astro.config.mjs
Normal 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
8944
docs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
docs/package.json
Normal file
27
docs/package.json
Normal 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
6
docs/src/assets/logo.svg
Normal 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 |
28
docs/src/components/CardGrid.astro
Normal file
28
docs/src/components/CardGrid.astro
Normal 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>
|
||||
46
docs/src/components/Diataxis.astro
Normal file
46
docs/src/components/Diataxis.astro
Normal 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>
|
||||
13
docs/src/content.config.ts
Normal file
13
docs/src/content.config.ts
Normal 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(),
|
||||
}),
|
||||
};
|
||||
121
docs/src/content/docs/explanation/cluster-isolated-cache.md
Normal file
121
docs/src/content/docs/explanation/cluster-isolated-cache.md
Normal 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
|
||||
145
docs/src/content/docs/explanation/hamilton-review-patterns.md
Normal file
145
docs/src/content/docs/explanation/hamilton-review-patterns.md
Normal 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
|
||||
143
docs/src/content/docs/explanation/pypi-yank-lesson.md
Normal file
143
docs/src/content/docs/explanation/pypi-yank-lesson.md
Normal 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
|
||||
95
docs/src/content/docs/explanation/read-only-by-structure.md
Normal file
95
docs/src/content/docs/explanation/read-only-by-structure.md
Normal 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.
|
||||
100
docs/src/content/docs/getting-started/configuration.md
Normal file
100
docs/src/content/docs/getting-started/configuration.md
Normal 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
|
||||
117
docs/src/content/docs/getting-started/first-audit.md
Normal file
117
docs/src/content/docs/getting-started/first-audit.md
Normal 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 |
|
||||
99
docs/src/content/docs/getting-started/installation.md
Normal file
99
docs/src/content/docs/getting-started/installation.md
Normal 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
|
||||
144
docs/src/content/docs/how-to/find-orphan-resources.md
Normal file
144
docs/src/content/docs/how-to/find-orphan-resources.md
Normal 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
|
||||
94
docs/src/content/docs/how-to/investigate-pattern.md
Normal file
94
docs/src/content/docs/how-to/investigate-pattern.md
Normal 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
|
||||
85
docs/src/content/docs/how-to/route-plan-overview.md
Normal file
85
docs/src/content/docs/how-to/route-plan-overview.md
Normal 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/)
|
||||
210
docs/src/content/docs/how-to/sip-trunk-report.md
Normal file
210
docs/src/content/docs/how-to/sip-trunk-report.md
Normal 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
|
||||
77
docs/src/content/docs/index.mdx
Normal file
77
docs/src/content/docs/index.mdx
Normal 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) |
|
||||
194
docs/src/content/docs/reference/cucm-schema-cheatsheet.md
Normal file
194
docs/src/content/docs/reference/cucm-schema-cheatsheet.md
Normal 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"
|
||||
167
docs/src/content/docs/reference/env-vars.md
Normal file
167
docs/src/content/docs/reference/env-vars.md
Normal 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/
|
||||
```
|
||||
145
docs/src/content/docs/reference/prompts.md
Normal file
145
docs/src/content/docs/reference/prompts.md
Normal 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
|
||||
254
docs/src/content/docs/reference/tools.md
Normal file
254
docs/src/content/docs/reference/tools.md
Normal 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
1
docs/src/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
165
docs/src/styles/custom.css
Normal file
165
docs/src/styles/custom.css
Normal 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
5
docs/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user