Refactor: the four existing inline prompts in server.py move into
individual modules under src/mcp_cucm_axl/prompts/. Server.py keeps
thin @mcp.prompt-decorated shims that delegate to the corresponding
render() function — FastMCP needs the shims because it introspects
their signatures to expose parameters to the LLM, but the prompt
*content* now lives one-prompt-per-file.
Why: server.py's prompt section had grown to ~200 lines of inline
markdown. As more query patterns get documented (see
docs/query-patterns/) this would only worsen. Per-module bodies are
easier to diff, review, and unit-test in isolation.
Layout:
src/mcp_cucm_axl/prompts/
__init__.py
_common.py — shared helpers, keyword sets, render_schema_block
route_plan_overview.py
investigate_pattern.py
audit_routing.py
cucm_sql_help.py
sip_trunk_report.py — NEW
Each prompt module exports a `render(docs, *args) -> str` function
that takes the DocsIndex as a parameter (no module globals). The
shim in server.py grabs the runtime `_docs` and passes it in. Pure
functions = trivially unit-testable.
NEW prompt: sip_trunk_report.
Implementation reference: docs/query-patterns/sip-trunk-report.md
(written separately as a query-pattern doc, validated against the
live cluster). The prompt embeds:
- Step 1: trunk inventory SQL (device + sipdevice + 5 LEFT JOINs)
- Step 2: per-destination SQL (siptrunkdestination)
- Step 3: pointer to existing route_lists_and_groups() tool
- Step 4: findings template (SPOF, profile sprawl, CSS asymmetry,
codec heterogeneity, DNS-vs-IP, security posture)
Optional `name_filter` parameter narrows the inventory via LIKE; the
filter value is escaped for SQL safety (single quotes doubled per
Informix convention).
Tests: 14 new in tests/test_prompts_package.py covering each
prompt's render() with and without docs, plus a registration smoke
test that confirms the FastMCP shim set matches the prompts package
exports (catches the case where a new module is added without its
shim).
Total: 100 → 114 tests; 5 prompts registered; live verification
against cucm-pub.binghammemorial.org confirms the embedded SQL
produces real inventory data. The four original prompts are
behaviorally identical to before — same content, just relocated.
168 lines
5.6 KiB
Python
168 lines
5.6 KiB
Python
"""Tests for the extracted prompts package.
|
|
|
|
The render() functions are pure (they take docs as a parameter, no module
|
|
globals), so they're trivially unit-testable. We verify each one renders
|
|
without raising both with and without a docs index.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from mcp_cucm_axl import prompts
|
|
from mcp_cucm_axl.docs_loader import DocsIndex
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_docs(tmp_path: Path) -> DocsIndex:
|
|
chunks = [
|
|
{
|
|
"id": "cucm::v15::admin::Route-Plan::0",
|
|
"text": "Route plan defines call routing.",
|
|
"heading_path": ["Route Plan"],
|
|
"source_path": str(tmp_path / "fake.md"),
|
|
"product": "cucm",
|
|
"version": "v15",
|
|
"doc": "system-config-guide",
|
|
},
|
|
{
|
|
"id": "cucm::v15::admin::SIP-Trunk::0",
|
|
"text": "SIP trunks connect CUCM to other call control entities.",
|
|
"heading_path": ["SIP Trunk Configuration"],
|
|
"source_path": str(tmp_path / "fake.md"),
|
|
"product": "cucm",
|
|
"version": "v15",
|
|
"doc": "system-config-guide",
|
|
},
|
|
]
|
|
(tmp_path / "chunks.jsonl").write_text(
|
|
"\n".join(json.dumps(c) for c in chunks)
|
|
)
|
|
(tmp_path / "index_meta.json").write_text(
|
|
json.dumps({"model_name": "test", "embedding_dim": 384, "products": ["cucm"]})
|
|
)
|
|
idx = DocsIndex.load(tmp_path)
|
|
assert idx is not None
|
|
return idx
|
|
|
|
|
|
# ---- route_plan_overview ----------------------------------------------------
|
|
|
|
def test_route_plan_overview_renders_with_docs(fake_docs):
|
|
text = prompts.route_plan_overview.render(fake_docs)
|
|
assert "Route Plan Overview" in text
|
|
assert "axl_version" in text
|
|
assert "route_partitions" in text
|
|
|
|
|
|
def test_route_plan_overview_renders_without_docs():
|
|
# Graceful degradation — no docs index should still produce a usable prompt
|
|
text = prompts.route_plan_overview.render(None)
|
|
assert "Route Plan Overview" in text
|
|
assert "cisco-docs index is not loaded" in text
|
|
|
|
|
|
# ---- investigate_pattern ---------------------------------------------------
|
|
|
|
def test_investigate_pattern_includes_pattern_in_output(fake_docs):
|
|
text = prompts.investigate_pattern.render(fake_docs, "10.911", "CER911-PT")
|
|
assert "10.911" in text
|
|
assert "CER911-PT" in text
|
|
|
|
|
|
def test_investigate_pattern_no_partition(fake_docs):
|
|
text = prompts.investigate_pattern.render(fake_docs, "9.@")
|
|
assert "9.@" in text
|
|
# The partition clause should not appear when no partition is given
|
|
assert "in partition" not in text
|
|
|
|
|
|
# ---- audit_routing ----------------------------------------------------------
|
|
|
|
def test_audit_routing_default_focus(fake_docs):
|
|
text = prompts.audit_routing.render(fake_docs)
|
|
assert "focus: `full`" in text
|
|
|
|
|
|
def test_audit_routing_custom_focus(fake_docs):
|
|
text = prompts.audit_routing.render(fake_docs, "translations")
|
|
assert "focus: `translations`" in text
|
|
|
|
|
|
# ---- cucm_sql_help ----------------------------------------------------------
|
|
|
|
def test_cucm_sql_help_includes_question(fake_docs):
|
|
q = "How do I find phones with no associated user?"
|
|
text = prompts.cucm_sql_help.render(fake_docs, q)
|
|
assert q in text
|
|
assert "axl_describe_table" in text
|
|
|
|
|
|
def test_cucm_sql_help_handles_empty_question(fake_docs):
|
|
# No substantive keywords — must still render something useful
|
|
text = prompts.cucm_sql_help.render(fake_docs, "x y z")
|
|
assert "axl_list_tables" in text
|
|
|
|
|
|
# ---- sip_trunk_report (NEW) ------------------------------------------------
|
|
|
|
def test_sip_trunk_report_includes_query_1(fake_docs):
|
|
text = prompts.sip_trunk_report.render(fake_docs)
|
|
# Query 1 essentials: device JOIN sipdevice, the trunk_name column,
|
|
# and the WHERE filter on Trunk class
|
|
assert "FROM device d" in text
|
|
assert "JOIN sipdevice sd" in text
|
|
assert "tc.name = 'Trunk'" in text
|
|
|
|
|
|
def test_sip_trunk_report_includes_query_2_and_tools(fake_docs):
|
|
text = prompts.sip_trunk_report.render(fake_docs)
|
|
assert "siptrunkdestination" in text
|
|
# And references the existing MCP tool for route group traversal
|
|
assert "route_lists_and_groups()" in text
|
|
|
|
|
|
def test_sip_trunk_report_with_name_filter_injects_safe_like(fake_docs):
|
|
text = prompts.sip_trunk_report.render(fake_docs, "PSTN")
|
|
# The filter should appear as a LIKE clause and the scope note should
|
|
# reflect the narrowing
|
|
assert "%PSTN%" in text
|
|
assert "narrowed to trunks" in text
|
|
|
|
|
|
def test_sip_trunk_report_name_filter_escaped(fake_docs):
|
|
# SQL injection defense: a single-quote in the name_filter must be
|
|
# doubled before going into the LIKE pattern
|
|
text = prompts.sip_trunk_report.render(fake_docs, "O'Reilly")
|
|
assert "O''Reilly" in text
|
|
|
|
|
|
def test_sip_trunk_report_renders_without_docs():
|
|
text = prompts.sip_trunk_report.render(None)
|
|
assert "SIP Trunk Report" in text
|
|
assert "cisco-docs index is not loaded" in text
|
|
|
|
|
|
# ---- registration smoke test -----------------------------------------------
|
|
|
|
def test_all_prompts_registered_in_server():
|
|
"""Confirm each prompt module's render() is wired through a FastMCP shim
|
|
in server.py. Catches the case where a new module is added but the
|
|
shim wasn't (the prompt would be invisible to the LLM)."""
|
|
import asyncio
|
|
from mcp_cucm_axl import server
|
|
|
|
async def _list():
|
|
registered = await server.mcp.list_prompts()
|
|
return {p.name for p in registered}
|
|
|
|
names = asyncio.run(_list())
|
|
assert names == {
|
|
"route_plan_overview",
|
|
"investigate_pattern",
|
|
"audit_routing",
|
|
"cucm_sql_help",
|
|
"sip_trunk_report",
|
|
}, f"unexpected prompt set: {names}"
|