Most teams have a procedure that only lives in someone’s head. Cutting release notes, triaging a breaking change, prepping an on-call handoff: five steps, done slightly differently every time, and badly the week the person who knows them is on vacation.

MCP gives you three primitives to fix that, and the interesting one is the least used. Tools are called by the model. Resources are read for context. Prompts are chosen by a person: named, parameterized templates a client surfaces as a slash command. That makes a prompt the natural home for a procedure — the user picks it, fills in one argument, and the model runs the same five steps in the same order every time.

This article builds a release-prep server with FastMCP whose prompts stage the server’s own tools into an ordered workflow, then drives it from Claude Code as /mcp__release-prep__draft_release_notes. The stack is Mac-native: uv, make, and pytest.

For the mechanics of prompt arguments, message roles, and embedded resources, see Design MCP Prompts: Arguments, Templates, and Embedded Resources. This article assumes those and focuses on using a prompt to drive a sequence of tool calls.

Why a workflow belongs in a prompt

There are three places to put a five-step procedure, and two of them are worse:

  • One mega-tool. Wrap the whole thing in do_release_prep(). It runs the same way every time, which is the point, but the model cannot adapt when a change record is malformed, and the user sees one opaque call instead of the steps. You have written a shell script with extra ceremony.
  • The user’s memory. Leave the steps to whoever is on release duty. They will type it differently every time, skip step 3 when they are in a hurry, and get a different result than the person who did it last month.
  • A prompt. The procedure is fixed and version-controlled; the individual steps stay as separate tools the model calls and can react to. The user types one slash command.

A prompt also has a property a tool does not: the prompt function runs on the server at render time. It can look up real data and inject it into the text the model receives, so step 1 starts from facts instead of guesses. That is what separates a workflow prompt from an f-string.

One limit, stated plainly because the rest of the article depends on it: a prompt emits instructions, not control flow. It cannot force the model to call a tool. Naming exact tools and arguments, keeping the step count small, and stating what “done” looks like all raise the odds considerably, and Step 8 shows a real run. But a prompt is a strong default, not a guarantee, and anything that must happen should be enforced in the tool itself.

What you will build

  • server.py: three single-purpose tools (list_changes, migration_note, render_notes), a policy://release-notes resource, and two prompts that sequence them.
  • client.py: renders the prompts and then walks the tool sequence they describe.
  • render_prompt.py: flattens a prompt to plain text so you can pipe it anywhere.
  • A pytest suite that treats the rendered plan as a contract.
  • A make wrapper that registers the server with Claude Code.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify with uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • Claude Code for Steps 7 and 8 (install guide). Verify with claude --version.
  • Familiarity with FastMCP and the in-memory Client (see Build an MCP Server with FastMCP and Python).

Versions used while writing: macOS 26.5.2 (Apple silicon), uv 0.11.26, FastMCP 3.4.5, Python 3.12, Claude Code 2.1.220.

Step 1: Add project hygiene

Create the file

mkdir -p mcp-prompt-workflows-macos
cd mcp-prompt-workflows-macos
touch .gitignore

Add the code: .gitignore

# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.log

# Generated release notes
out/

# OS / editor noise
.DS_Store

Detailed breakdown

  • Standard Python ignores, written before anything else so the virtualenv and caches never reach a commit.
  • out/ is reserved for rendered notes if you later have render_notes write to disk instead of returning a string.

Step 2: Initialize the project with uv

Create the files

uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio

Add the code: pyproject.toml

[project]
name = "macmcp"
version = "0.1.0"
description = "MCP prompts that trigger multi-step release-prep workflows"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "fastmcp>=3.4.5",
]

[dependency-groups]
dev = [
    "pytest>=9.1.1",
    "pytest-asyncio>=1.4.0",
]

Detailed breakdown

  • fastmcp is the only runtime dependency. Message comes from fastmcp.prompts.prompt; EmbeddedResource and TextResourceContents come from the mcp package FastMCP installs.
  • pytest-asyncio runs the async client tests in Step 6.
  • uv init also writes main.py, which this project does not use.

Step 3: Add the fixture data

The workflow needs something to work on. A small JSON file keeps the article reproducible; in a real server this would be your issue tracker or git log.

Create the file

mkdir -p data
touch data/changes.json

Add the code: data/changes.json

[
  {
    "id": "CH-101",
    "release": "2.4.0",
    "type": "feat",
    "component": "search",
    "summary": "Add fuzzy matching to the document search index.",
    "breaking": false,
    "migration": null
  },
  {
    "id": "CH-102",
    "release": "2.4.0",
    "type": "fix",
    "component": "auth",
    "summary": "Refresh tokens no longer expire early under clock skew.",
    "breaking": false,
    "migration": null
  },
  {
    "id": "CH-103",
    "release": "2.4.0",
    "type": "feat",
    "component": "api",
    "summary": "Replace the limit query parameter with page_size.",
    "breaking": true,
    "migration": "Rename limit to page_size. The old name is accepted until 3.0.0 and logs a deprecation warning."
  },
  {
    "id": "CH-104",
    "release": "2.4.0",
    "type": "chore",
    "component": "build",
    "summary": "Drop Python 3.10 from the supported test matrix.",
    "breaking": true,
    "migration": "Upgrade to Python 3.11 or newer before installing 2.4.0."
  },
  {
    "id": "CH-105",
    "release": "2.3.1",
    "type": "fix",
    "component": "search",
    "summary": "Escape regex metacharacters in user-supplied queries.",
    "breaking": false,
    "migration": null
  }
]

Detailed breakdown

  • Release 2.4.0 has four changes, two of them breaking (CH-103, CH-104). Those counts appear verbatim in the rendered prompt later, so they are worth noting now.
  • Release 2.3.1 has one change and nothing breaking. It exercises the branch where a workflow should stop early instead of running a pointless triage.
  • migration is null for non-breaking changes, which gives migration_note a meaningful answer for every id rather than an error.

Step 4: The server, its tools, and its workflow prompts

The tools stay small and single-purpose. The prompts do the sequencing.

Create the file

touch server.py

Add the code: server.py

"""A release-prep MCP server whose prompts trigger multi-step workflows.

The three tools here are deliberately small and single-purpose. The prompts are
what turn them into a repeatable procedure: each prompt renders an ordered plan
that names the exact tools and arguments to use, ships the house policy inline as
an embedded resource, and injects facts the server looked up while rendering, so
the model starts from real data instead of guessing.

Run over stdio with `python server.py`, or drive it with client.py.
"""

import json
from pathlib import Path

from fastmcp import FastMCP
from fastmcp.prompts.prompt import Message
from mcp.types import EmbeddedResource, TextResourceContents

mcp = FastMCP("release-prep")

DATA = Path(__file__).parent / "data" / "changes.json"
POLICY_URI = "policy://release-notes"

POLICY = """Release-notes house rules:
1. Lead with what a reader can now do, not with the internal component name.
2. Every breaking change gets a "Migration" line written in the imperative.
3. One line per change. No marketing adjectives.
4. Order sections: Highlights, Breaking changes, All changes.
"""


def load_changes() -> list[dict]:
    """Every change record on disk."""
    return json.loads(DATA.read_text())


def changes_for(release: str) -> list[dict]:
    """Change records for one release, in file order."""
    return [c for c in load_changes() if c["release"] == release]


def known_releases() -> list[str]:
    """Sorted list of releases that have at least one change."""
    return sorted({c["release"] for c in load_changes()})


def require_release(release: str) -> list[dict]:
    """Resolve a release or fail now, while the prompt is being rendered."""
    changes = changes_for(release)
    if not changes:
        raise ValueError(
            f"Unknown release {release!r}. Known releases: {', '.join(known_releases())}"
        )
    return changes


@mcp.resource(POLICY_URI)
def release_policy() -> str:
    """The house rules for writing release notes."""
    return POLICY


@mcp.tool
def list_changes(release: str) -> list[dict]:
    """List every recorded change for a release."""
    return require_release(release)


@mcp.tool
def migration_note(change_id: str) -> dict:
    """Return the migration note for one breaking change."""
    for change in load_changes():
        if change["id"] == change_id:
            if not change["breaking"]:
                return {"id": change_id, "breaking": False, "migration": None}
            return {
                "id": change_id,
                "breaking": True,
                "migration": change["migration"],
            }
    raise ValueError(f"Unknown change id {change_id!r}")


@mcp.tool
def render_notes(release: str, highlights: list[str]) -> str:
    """Render final release notes from the highlight lines you wrote."""
    changes = require_release(release)
    breaking = [c for c in changes if c["breaking"]]

    lines = [f"# {release}", "", "## Highlights", ""]
    lines += [f"- {h}" for h in highlights]

    if breaking:
        lines += ["", "## Breaking changes", ""]
        for change in breaking:
            lines.append(f"- {change['id']} ({change['component']}): {change['summary']}")
            lines.append(f"  Migration: {change['migration']}")

    lines += ["", "## All changes", ""]
    for change in changes:
        lines.append(
            f"- {change['id']} {change['type']}({change['component']}): {change['summary']}"
        )

    return "\n".join(lines) + "\n"


def policy_message() -> Message:
    """Wrap the policy resource so a prompt can carry it inline by URI."""
    return Message(
        role="user",
        content=EmbeddedResource(
            type="resource",
            resource=TextResourceContents(
                uri=POLICY_URI,
                mimeType="text/plain",
                text=POLICY,
            ),
        ),
    )


@mcp.prompt
def draft_release_notes(release: str, audience: str = "developers") -> list[Message]:
    """Run the full release-notes workflow for a release."""
    changes = require_release(release)
    breaking = [c["id"] for c in changes if c["breaking"]]
    breaking_text = ", ".join(breaking) if breaking else "none"

    plan = f"""You are drafting release notes for {release}, written for {audience}.

Follow these steps in order and do not skip one:

1. Call `list_changes` with release="{release}" to read all {len(changes)} changes.
2. For each breaking change ({breaking_text}), call `migration_note` with its id
   and keep the migration text verbatim.
3. Write exactly one highlight line per change, following the policy below. Do
   not put migration text in a highlight line; `render_notes` builds the
   Breaking changes section from the records itself.
4. Call `render_notes` with release="{release}" and your highlight lines.
5. Show the rendered notes and stop. Do not invent changes that are not listed.

The attached policy resource is authoritative. If a highlight line would break a
rule in it, rewrite the line rather than the rule."""

    request = (
        f"Draft the {release} release notes. This release has {len(changes)} changes "
        f"and {len(breaking)} breaking change(s): {breaking_text}."
    )

    return [
        Message(role="assistant", content=plan),
        policy_message(),
        Message(role="user", content=request),
    ]


@mcp.prompt
def triage_breaking_changes(release: str) -> list[Message]:
    """Check that every breaking change in a release has a usable migration note."""
    changes = require_release(release)
    breaking = [c["id"] for c in changes if c["breaking"]]

    if not breaking:
        return [
            Message(
                role="user",
                content=(
                    f"Release {release} has {len(changes)} change(s) and no breaking "
                    "changes. Confirm that and stop; no triage is needed."
                ),
            )
        ]

    plan = f"""Triage the breaking changes in {release}.

1. Call `migration_note` for each of: {", ".join(breaking)}.
2. For each note, judge it against two tests: does it name the exact thing that
   changed, and does it tell the reader what to do in the imperative?
3. Report one line per change: the id, pass or fail, and the reason if it fails.
4. Do not call `render_notes`. This workflow ends with the report."""

    return [
        Message(role="assistant", content=plan),
        Message(
            role="user",
            content=f"Triage the {len(breaking)} breaking change(s) in {release}.",
        ),
    ]


if __name__ == "__main__":
    mcp.run()

Detailed breakdown

  • require_release is the load-bearing helper. Both tools and both prompts call it. In a tool it turns a bad argument into a tool error; in a prompt it fails while the prompt is rendering, so the user sees the mistake before the model sees anything. Step 6 pins that behavior.
  • The tools are boring on purpose. list_changes reads, migration_note looks up one record, render_notes formats. None of them knows there is a workflow. That is what keeps them reusable by the second prompt, by a different prompt you add later, and by a user who just wants one lookup.
  • draft_release_notes builds the plan from real data. It calls require_release and computes len(changes) and the breaking ids before writing the text, so the model is told “read all 4 changes” and “(CH-103, CH-104)” rather than being asked to discover them. The count also acts as a checksum: if the model reports three changes, the plan and the result disagree visibly.
  • Step 3 carries a negative instruction for a reason. The first version said only “write one highlight line per change,” and a real run duplicated every migration note into the highlights. Step 8 shows that run. The fix was to say what not to do and name the tool that already handles it.
  • The plan names tools and arguments literally. `render_notes` with release="2.4.0" is far more likely to produce the right call than “render the notes.” Backticks around tool names help too, since they match how the tool appears in the model’s tool list.
  • Every step is bounded. Step 5 says “and stop”; the triage plan says “Do not call render_notes.” Without an explicit end, a model that has just been given a formatting tool will often use it.
  • The policy travels as an embedded resource, not as pasted text. It stays addressable at policy://release-notes, so the model can re-read the canonical copy, and editing POLICY updates every workflow at once.
  • triage_breaking_changes short-circuits. When a release has nothing breaking, it returns a single user message saying so instead of a plan with nothing to do. A workflow prompt should be able to decide there is no work.
  • Roles are user and assistant only. MCP prompt messages have no system role, so the plan goes in an assistant turn and the concrete ask in a user turn.

Step 5: Render the prompts and walk the workflow

Create the file

touch client.py

Add the code: client.py

"""Render the workflow prompts, then run the workflow one of them describes.

Part 1 shows what a client sees: the prompt list with its arguments, and the
messages `draft_release_notes` renders. Part 2 walks the same tool sequence the
prompt asks the model to follow, so you can check the procedure actually produces
release notes before you point a real client at it.
"""

import asyncio

from fastmcp import Client

from server import mcp

RELEASE = "2.4.0"


def show(label: str, body: str) -> None:
    print(f"\n{label}")
    print("-" * len(label))
    print(body)


async def main() -> None:
    async with Client(mcp) as client:
        print("=== Prompts this server offers ===\n")
        for prompt in await client.list_prompts():
            args = ", ".join(
                f"{a.name}{'' if a.required else '?'}" for a in prompt.arguments
            )
            print(f"/{prompt.name}({args})  {prompt.description}")

        print(f"\n\n=== Rendering draft_release_notes(release={RELEASE!r}) ===")
        rendered = await client.get_prompt("draft_release_notes", {"release": RELEASE})
        for i, message in enumerate(rendered.messages, start=1):
            content = message.content
            if content.type == "resource":
                body = f"[embedded resource {content.resource.uri}]\n{content.resource.text}"
            else:
                body = content.text
            show(f"message {i} ({message.role}, {content.type})", body)

        print("\n\n=== Running the workflow the prompt describes ===")
        changes = (await client.call_tool("list_changes", {"release": RELEASE})).data
        print(f"\nstep 1: list_changes -> {len(changes)} changes")

        for change in changes:
            if change["breaking"]:
                note = (
                    await client.call_tool("migration_note", {"change_id": change["id"]})
                ).data
                print(f"step 2: migration_note({change['id']}) -> {note['migration']}")

        highlights = [
            "Search now matches close spellings, so typos still find the document.",
            "Sessions survive clock skew between your machine and the auth service.",
            "Paging uses page_size; limit still works until 3.0.0.",
            "Python 3.11 is the minimum supported version.",
        ]
        notes = (
            await client.call_tool(
                "render_notes", {"release": RELEASE, "highlights": highlights}
            )
        ).data
        show("step 4: render_notes ->", notes)


if __name__ == "__main__":
    asyncio.run(main())

Detailed breakdown

  • The in-memory Client(mcp) speaks the real protocol without a subprocess, so list_prompts and get_prompt return exactly what a remote client would.
  • prompt.arguments carries a required flag derived from the function signature: release has no default and is required, audience has one and is not. The listing marks optional arguments with ?.
  • The second half hard-codes the highlight lines a model would have written. That is the point of it: it proves the tool sequence produces valid notes, independent of any model behavior, so a failure in Step 8 is unambiguously a prompt problem rather than a plumbing problem.

Run it:

uv run python client.py

Trimmed output:

=== Prompts this server offers ===

/draft_release_notes(release, audience?)  Run the full release-notes workflow for a release.
/triage_breaking_changes(release)  Check that every breaking change in a release has a usable migration note.


=== Rendering draft_release_notes(release='2.4.0') ===

message 1 (assistant, text)
---------------------------
You are drafting release notes for 2.4.0, written for developers.

Follow these steps in order and do not skip one:

1. Call `list_changes` with release="2.4.0" to read all 4 changes.
2. For each breaking change (CH-103, CH-104), call `migration_note` with its id
   and keep the migration text verbatim.
...

message 2 (user, resource)
--------------------------
[embedded resource policy://release-notes]
Release-notes house rules:
1. Lead with what a reader can now do, not with the internal component name.
...

message 3 (user, text)
----------------------
Draft the 2.4.0 release notes. This release has 4 changes and 2 breaking change(s): CH-103, CH-104.


=== Running the workflow the prompt describes ===

step 1: list_changes -> 4 changes
step 2: migration_note(CH-103) -> Rename limit to page_size. The old name is accepted until 3.0.0 and logs a deprecation warning.
step 2: migration_note(CH-104) -> Upgrade to Python 3.11 or newer before installing 2.4.0.

step 4: render_notes ->
-----------------------
# 2.4.0

## Highlights

- Search now matches close spellings, so typos still find the document.
...

## Breaking changes

- CH-103 (api): Replace the limit query parameter with page_size.
  Migration: Rename limit to page_size. The old name is accepted until 3.0.0 and logs a deprecation warning.
- CH-104 (build): Drop Python 3.10 from the supported test matrix.
  Migration: Upgrade to Python 3.11 or newer before installing 2.4.0.

## All changes

- CH-101 feat(search): Add fuzzy matching to the document search index.
- CH-102 fix(auth): Refresh tokens no longer expire early under clock skew.
- CH-103 feat(api): Replace the limit query parameter with page_size.
- CH-104 chore(build): Drop Python 3.10 from the supported test matrix.

Note the injected facts in message 1: “all 4 changes” and “(CH-103, CH-104)” were computed by the server, not guessed by the model.

Step 6: Test the plan as a contract

The text a workflow prompt renders is an interface. Edit it carelessly and the model quietly stops calling a tool, with no exception anywhere. These tests pin the parts that matter.

Create the files

mkdir -p tests
touch tests/__init__.py tests/test_workflow_prompts.py pytest.ini

Add the code: pytest.ini

[pytest]
asyncio_mode = auto
filterwarnings =
    ignore::DeprecationWarning

Add the code: tests/test_workflow_prompts.py

"""Tests for the release-prep workflow prompts.

A workflow prompt is worth testing for the same reason a tool is: the text it
renders is a contract with the model. These tests pin the parts that a careless
edit would break — the ordered tool plan, the facts injected at render time, the
embedded policy, and the render-time failure on a bad argument.
"""

import pytest
from fastmcp import Client
from mcp.shared.exceptions import McpError

from server import mcp

RELEASE = "2.4.0"
QUIET_RELEASE = "2.3.1"


async def render(name: str, args: dict):
    async with Client(mcp) as client:
        return await client.get_prompt(name, args)


async def test_prompts_declare_required_and_optional_arguments():
    async with Client(mcp) as client:
        prompts = {p.name: p for p in await client.list_prompts()}

    assert set(prompts) == {"draft_release_notes", "triage_breaking_changes"}
    required = {a.name: a.required for a in prompts["draft_release_notes"].arguments}
    assert required == {"release": True, "audience": False}


async def test_plan_names_each_tool_in_order():
    result = await render("draft_release_notes", {"release": RELEASE})
    plan = result.messages[0].content.text

    positions = [plan.index(tool) for tool in ("list_changes", "migration_note", "render_notes")]
    assert positions == sorted(positions)


async def test_plan_injects_facts_looked_up_at_render_time():
    result = await render("draft_release_notes", {"release": RELEASE})
    plan = result.messages[0].content.text

    # 4 changes in 2.4.0, of which CH-103 and CH-104 are breaking.
    assert "all 4 changes" in plan
    assert "(CH-103, CH-104)" in plan
    assert "CH-105" not in plan  # belongs to 2.3.1


async def test_optional_audience_argument_changes_the_framing():
    default = await render("draft_release_notes", {"release": RELEASE})
    assert "written for developers" in default.messages[0].content.text

    override = await render(
        "draft_release_notes", {"release": RELEASE, "audience": "site operators"}
    )
    assert "written for site operators" in override.messages[0].content.text


async def test_policy_travels_as_an_embedded_resource():
    result = await render("draft_release_notes", {"release": RELEASE})
    resource = result.messages[1].content

    assert resource.type == "resource"
    assert str(resource.resource.uri) == "policy://release-notes"
    assert "Migration" in resource.resource.text


async def test_roles_are_only_user_and_assistant():
    result = await render("draft_release_notes", {"release": RELEASE})
    assert [m.role for m in result.messages] == ["assistant", "user", "user"]


async def test_unknown_release_fails_while_rendering():
    # The client sees a protocol error, not a rendered prompt: the bad argument
    # never reaches the model.
    with pytest.raises(McpError) as excinfo:
        await render("draft_release_notes", {"release": "9.9.9"})

    message = str(excinfo.value)
    assert "Unknown release '9.9.9'" in message
    assert "2.3.1, 2.4.0" in message


async def test_triage_prompt_covers_exactly_the_breaking_changes():
    result = await render("triage_breaking_changes", {"release": RELEASE})
    plan = result.messages[0].content.text

    assert "CH-103, CH-104" in plan
    assert "Do not call `render_notes`" in plan


async def test_triage_prompt_short_circuits_when_nothing_is_breaking():
    result = await render("triage_breaking_changes", {"release": QUIET_RELEASE})

    assert len(result.messages) == 1
    assert result.messages[0].role == "user"
    assert "no breaking changes" in result.messages[0].content.text


async def test_render_notes_omits_the_breaking_section_when_empty():
    async with Client(mcp) as client:
        notes = (
            await client.call_tool(
                "render_notes", {"release": QUIET_RELEASE, "highlights": ["Safer search."]}
            )
        ).data

    assert "## Breaking changes" not in notes
    assert "CH-105 fix(search)" in notes


async def test_workflow_tools_round_trip():
    async with Client(mcp) as client:
        changes = (await client.call_tool("list_changes", {"release": RELEASE})).data
        assert [c["id"] for c in changes] == ["CH-101", "CH-102", "CH-103", "CH-104"]

        note = (await client.call_tool("migration_note", {"change_id": "CH-103"})).data
        assert note["breaking"] is True
        assert note["migration"].startswith("Rename limit to page_size")

        quiet = (await client.call_tool("migration_note", {"change_id": "CH-101"})).data
        assert quiet["breaking"] is False
        assert quiet["migration"] is None

Detailed breakdown

  • test_plan_names_each_tool_in_order asserts on the order of the tool names in the text, not just their presence. Reordering the steps by accident is the classic way to break a workflow prompt while every other test still passes.
  • test_plan_injects_facts_looked_up_at_render_time is the test that would catch a regression from computed facts back to a static template. The "CH-105" not in plan assertion checks the filtering, since that id belongs to a different release.
  • test_unknown_release_fails_while_rendering documents a detail worth knowing: the ValueError raised inside the prompt function surfaces to the client as an McpError from mcp.shared.exceptions, wrapped by FastMCP as Error rendering prompt 'draft_release_notes': .... Catching PromptError from fastmcp.exceptions does not work here; that is the server-side type, and the client sees the protocol error. The original message survives the trip, which is why listing the known releases in it is worth doing.
  • test_triage_prompt_short_circuits_when_nothing_is_breaking covers the branch that returns one message instead of a plan.
  • tests/__init__.py is required so pytest puts the project root on sys.path and from server import mcp resolves.

Run them:

uv run pytest -q
...........                                                              [100%]
11 passed in 0.35s

Step 7: Wrap it in a Makefile and register with Claude Code

Create the files

touch Makefile render_prompt.py

Add the code: render_prompt.py

"""Print a prompt's rendered messages as plain text.

A slash command is only a delivery mechanism: what reaches the model is the text
the prompt rendered. Flattening it here lets you pipe the same text into any
client (`claude -p`, a test harness, a diff) and check that the procedure holds
up on its own.

Usage: uv run python render_prompt.py <prompt-name> <release> [audience]
"""

import asyncio
import sys

from fastmcp import Client

from server import mcp


async def main() -> None:
    if len(sys.argv) < 3:
        sys.exit(__doc__.strip().splitlines()[-1])

    name, release = sys.argv[1], sys.argv[2]
    args = {"release": release}
    if len(sys.argv) > 3:
        args["audience"] = sys.argv[3]

    async with Client(mcp) as client:
        result = await client.get_prompt(name, args)

    for message in result.messages:
        content = message.content
        if content.type == "resource":
            print(content.resource.text)
        else:
            print(content.text)
        print()


if __name__ == "__main__":
    asyncio.run(main())

Add the code: Makefile

.DEFAULT_GOAL := help

# Claude Code launches the server without your shell's PATH or working
# directory, so the register recipe resolves `uv` and passes the project
# directory explicitly.
NAME    ?= release-prep
RELEASE ?= 2.4.0
UV      := $(shell command -v uv)
DIR     := $(shell pwd)
TOOLS   := mcp__$(NAME)__list_changes mcp__$(NAME)__migration_note mcp__$(NAME)__render_notes

.PHONY: help install demo render workflow serve test test-v register show unregister clean

help:  ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-12s\033[0m %s\n", $$1, $$2}'

install:  ## Sync runtime and dev dependencies
	uv sync

demo:  ## Render the workflow prompts and run the workflow they describe
	uv run python client.py

render:  ## Print the text draft_release_notes renders (RELEASE=2.4.0)
	uv run python render_prompt.py draft_release_notes $(RELEASE)

workflow:  ## Feed that text to Claude Code and let it run the workflow
	uv run python render_prompt.py draft_release_notes $(RELEASE) \
		| claude -p --allowedTools "$(TOOLS)"

serve:  ## Run the server over stdio (for a real MCP client)
	uv run python server.py

test:  ## Run the test suite
	uv run pytest -q

test-v:  ## Run the test suite verbosely
	uv run pytest -v

register:  ## Register the server with Claude Code (local scope)
	claude mcp add $(NAME) -- $(UV) run --directory $(DIR) python server.py

show:  ## Show the registered server and its connection status
	claude mcp get $(NAME)

unregister:  ## Remove the server from Claude Code
	-claude mcp remove $(NAME)

clean:  ## Remove caches
	rm -rf .pytest_cache __pycache__ tests/__pycache__

Detailed breakdown

  • .DEFAULT_GOAL := help makes bare make print the target list.
  • UV := $(shell command -v uv) and DIR := $(shell pwd) matter because Claude Code launches a stdio server without your shell’s PATH or working directory. A bare uv in the recipe produces a server that connects from your terminal and fails from the client.
  • TOOLS builds the fully-qualified tool names Claude Code uses for an MCP server, mcp__<server>__<tool>. Passing them to --allowedTools lets the Step 8 run proceed without a permission prompt per call.
  • RELEASE ?= 2.4.0 lets you run make workflow RELEASE=2.3.1 against the other fixture release.

Register the server:

make register
make show
release-prep:
  Scope: Local config (private to you in this project)
  Status: ✔ Connected
  Type: stdio
  Command: /opt/homebrew/bin/uv
  Args: run --directory /path/to/mcp-prompt-workflows-macos python server.py
  Environment:

In an interactive claude session, the prompts now appear as slash commands named /mcp__<servername>__<promptname>. Type / to see them, and pass arguments space-separated (quote any that contain spaces):

/mcp__release-prep__draft_release_notes 2.4.0
/mcp__release-prep__draft_release_notes 2.4.0 "site operators"
/mcp__release-prep__triage_breaking_changes 2.4.0

That is the payoff. The person cutting the release types one line and gets the same five steps every time, and the procedure lives in server.py under review like any other code.

make unregister removes the server when you are done.

Step 8: Run the workflow end to end

Slash commands are an interactive-session feature. claude -p connects to the server and will happily call its tools, but it does not resolve MCP prompt commands: claude -p "/mcp__release-prep__draft_release_notes 2.4.0" answers Unknown command: /mcp__release-prep__draft_release_notes.

That is what render_prompt.py is for. The slash command is only delivery, so piping the rendered text in gives the model the same input over a scriptable path:

make workflow

The model calls the three tools in order and prints the result:

# 2.4.0

## Highlights

- Find documents even when your query is misspelled — search now matches fuzzily.
- Stay signed in on machines whose clocks drift; refresh tokens no longer expire early.
- Page through API results with the page_size query parameter, which replaces limit.
- Run on Python 3.11 or newer; 3.10 is no longer in the supported test matrix.

## Breaking changes

- CH-103 (api): Replace the limit query parameter with page_size.
  Migration: Rename limit to page_size. The old name is accepted until 3.0.0 and logs a deprecation warning.
- CH-104 (build): Drop Python 3.10 from the supported test matrix.
  Migration: Upgrade to Python 3.11 or newer before installing 2.4.0.

## All changes

- CH-101 feat(search): Add fuzzy matching to the document search index.
- CH-102 fix(auth): Refresh tokens no longer expire early under clock skew.
- CH-103 feat(api): Replace the limit query parameter with page_size.
- CH-104 chore(build): Drop Python 3.10 from the supported test matrix.

Four highlights for four changes, each leading with a reader action rather than a component name, and both migration lines carried verbatim from migration_note. The model also re-read policy://release-notes by URI rather than trusting the copy in the prompt.

The prose around the notes varies between runs, and so does the exact wording of the highlight lines: they are model output, not template output. The structure below render_notes does not vary, because that part is code.

What the first run got wrong

The step 3 in Step 4 has a negative instruction bolted onto it. That is not foresight, it is a scar. The first version read only:

3. Write one highlight line per user-visible change, following the policy below.

The model wrote the four highlights, then added both migration lines to the highlight list as well, so the rendered notes carried each migration twice: once under Highlights and again under Breaking changes. Nothing errored. Every test passed. The output was simply worse than it should have been.

The instruction was ambiguous about a boundary the model could not see: it had no way to know render_notes already emits a Breaking changes section. Naming that explicitly fixed it:

3. Write exactly one highlight line per change, following the policy below. Do
   not put migration text in a highlight line; `render_notes` builds the
   Breaking changes section from the records itself.

The general lesson: run the workflow before you ship the prompt, and when output disappoints, look for the boundary the model could not infer. Prompt text deserves the same iteration as code, which is also the argument for keeping it in server.py instead of in a wiki page.

Designing workflow prompts that hold up

  • One prompt, one procedure. If a prompt needs a mode argument that switches between two procedures, it is two prompts. Clients list prompts by name, and two clear names beat one with a mode flag.
  • Number the steps and name the tools literally. Call `migration_note` with its id beats “check the migration notes.” Include argument names and values you already know.
  • Compute what you can at render time. Anything the server can look up should be injected, not delegated. It shortens the workflow and gives you a checksum against the model’s own results.
  • Bound the end. State what the last step is and that the model should stop. Prompts without an ending tend to keep going.
  • Say what not to do, but only where a boundary is invisible. A model cannot know that render_notes already handles migrations. It can be told once.
  • Keep the tools independent of the workflow. No tool in this server knows a procedure exists, which is why a second prompt could reuse them unchanged.
  • Validate arguments in the prompt function. A bad release should fail at render time with a message naming the valid options, not become a hallucinated changelog.
  • Enforce hard requirements in tools, not prose. If a step must never be skipped, make the next tool refuse to run without evidence of it. Prompt text raises the odds; a precondition check settles the matter.

Troubleshooting

  • Unknown command: /mcp__... from claude -p. Prompt slash commands are resolved in interactive sessions only. Use an interactive claude, or pipe the rendered prompt text in as make workflow does.
  • The slash command does not appear after make register. Check make show reports ✔ Connected. Local-scope servers are tied to the directory you registered from, so start claude in that directory. Restart the session after registering.
  • The server connects from the terminal but not from Claude Code. The recipe must use an absolute uv path and --directory; the client launches it without your shell PATH or working directory.
  • ValidationError on Message(role="system", ...). MCP prompt messages allow only user and assistant. Put the plan in an assistant turn.
  • messages[0] must be Message or str, got PromptMessage. Wrap each turn in Message from fastmcp.prompts.prompt.
  • Catching PromptError in a test does not work. That is the server-side exception. A client sees McpError from mcp.shared.exceptions, carrying the wrapped message. FastMCP also logs the original traceback to stderr, which is noise, not a failure.
  • The model skips a step. Check the step names an exact tool, that the step count is small, and that nothing earlier told it to stop. If the step is truly mandatory, move the requirement into a tool precondition.
  • ModuleNotFoundError: No module named 'server' under pytest. tests/ needs an __init__.py.

Recap

Prompts are where a procedure belongs. This server kept three tools small and ignorant of each other, then used prompts to sequence them: an ordered plan naming each tool and its arguments, facts computed at render time so the model starts from real data, the house policy embedded by URI, and an explicit ending. A bad argument fails while the prompt renders rather than becoming an invented changelog, and the plan is covered by tests that treat it as the contract it is. Registered with Claude Code, the whole thing is one slash command.

Next improvements:

  • Add argument completions so a client autocompletes release from the data file (see Add Argument Completions to an MCP Server).
  • Give render_notes a precondition that rejects a highlight count that does not match the change count, turning a prose instruction into an enforced one.
  • Bundle the server as a Claude Code plugin so a team gets the slash commands without registering anything (see Bundle an MCP Server in a Claude Code Plugin).