Skip to content

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.

  1. Add a @mcp.prompt function

    Open src/mcsinglewire/server.py and find _register_prompts(mcp). Add a new function inside it. The pattern is small:

    @mcp.prompt
    def 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 operationId in 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 use openapi_search to 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.
  2. 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 survived
    async 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 text
    assert "7 days" not in text

    Also 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.

  3. Run the suite

    Lint + tests
    make check

    If your phrasing accidentally drops one of the safety words you asserted on (e.g. “Read only”), the test fails before it ships.

  4. 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.

  5. Ship

    Rebuild and redeploy
    docker compose up -d --build mcsinglewire docs

    Restart your MCP client (or have it re-list tools/prompts) so the new slash command appears.

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.