Add a custom prompt
The fix is to register the audit as an MCP prompt. It shows up as a slash command in Claude Code (and in any other MCP client’s prompt picker), takes typed arguments, and produces the same vetted phrasing every time.
This how-to assumes you’re working in a checked-out clone of the
repo with make check working — see
Getting started for the operator
side.
-
Add a
@mcp.promptfunctionOpen
src/mcsinglewire/server.pyand find_register_prompts(mcp). Add a new function inside it. The pattern is small:@mcp.promptdef my_audit(window: int = 7) -> str:"""Short title shown in the slash-command picker.Args:window: Number of days to cover. Defaults to 7."""return (f"Find every X in the Singlewire system created in the last "f"{window} days. For each, show ... Read only — do not "f"modify, retry, or trigger anything.")The docstring becomes the description users see in the prompt picker; the return value is the message body Claude receives. Three guardrails:
- Stay spec-agnostic. Don’t hard-code an
operationIdin the prompt body — refresh the spec next month and you’ll be debugging why the prompt stopped working. Describe the intent and let the LLM useopenapi_searchto find the right endpoint. - Be explicit about read-only intent. The server enforces it three layers down, but stating it in the prompt body keeps the model from drifting toward “let me also try …” when the question has an obvious mutation neighbour.
- Type-annotate every argument. FastMCP coerces wire-level string arguments to your annotated types; without annotations the arguments arrive as raw strings.
- Stay spec-agnostic. Don’t hard-code an
-
Add a test
Append to
tests/test_prompts.py. There are two assertions worth making:async def test_my_audit_default_window(mcp: FastMCP) -> None:result = await mcp.render_prompt("my_audit", {})text = _text(result)assert "7 days" in text # default window survivedasync def test_my_audit_honours_custom_window(mcp: FastMCP) -> None:result = await mcp.render_prompt("my_audit", {"window": "30"})text = _text(result)assert "30 days" in textassert "7 days" not in textAlso extend the registration test at the top of the file so the new prompt is in the expected set:
async def test_expected_prompts_are_registered(mcp: FastMCP) -> None:prompts = await mcp.list_prompts()names = {p.name for p in prompts}assert names == {# ...existing names..."my_audit",}That set assertion is on purpose. If someone renames a prompt, the test fails loudly — slash commands in clients break silently otherwise.
-
Run the suite
Lint + tests make checkIf your phrasing accidentally drops one of the safety words you asserted on (e.g. “Read only”), the test fails before it ships.
-
Document it
Add an entry to
docs/src/content/docs/reference/prompts.mdx:- Add the row to the appropriate group table.
- Add a per-prompt section with the signature, what it does, when to reach for it, and the argument table if any.
The structure is the same for every prompt; the existing entries are good templates.
-
Ship
Rebuild and redeploy docker compose up -d --build mcsinglewire docsRestart your MCP client (or have it re-list tools/prompts) so the new slash command appears.
Verification
Section titled “Verification”In Claude Code, type / and look for mcp__singlewire__my_audit.
Pick it, fill in the argument, and confirm the answer comes back
shaped the way you wanted. The audit log will show one entry per
upstream call as usual.