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), apolicy://release-notesresource, 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
pytestsuite that treats the rendered plan as a contract. - A
makewrapper that registers the server with Claude Code.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verify withuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - 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 haverender_noteswrite 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
fastmcpis the only runtime dependency.Messagecomes fromfastmcp.prompts.prompt;EmbeddedResourceandTextResourceContentscome from themcppackage FastMCP installs.pytest-asyncioruns the async client tests in Step 6.uv initalso writesmain.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.0has 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.1has one change and nothing breaking. It exercises the branch where a workflow should stop early instead of running a pointless triage. migrationisnullfor non-breaking changes, which givesmigration_notea 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_releaseis 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_changesreads,migration_notelooks up one record,render_notesformats. 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_notesbuilds the plan from real data. It callsrequire_releaseand computeslen(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 editingPOLICYupdates every workflow at once. triage_breaking_changesshort-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
userandassistantonly. MCP prompt messages have nosystemrole, so the plan goes in anassistantturn and the concrete ask in auserturn.
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, solist_promptsandget_promptreturn exactly what a remote client would. prompt.argumentscarries arequiredflag derived from the function signature:releasehas no default and is required,audiencehas 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_orderasserts 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_timeis the test that would catch a regression from computed facts back to a static template. The"CH-105" not in planassertion checks the filtering, since that id belongs to a different release.test_unknown_release_fails_while_renderingdocuments a detail worth knowing: theValueErrorraised inside the prompt function surfaces to the client as anMcpErrorfrommcp.shared.exceptions, wrapped by FastMCP asError rendering prompt 'draft_release_notes': .... CatchingPromptErrorfromfastmcp.exceptionsdoes 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_breakingcovers the branch that returns one message instead of a plan.tests/__init__.pyis required so pytest puts the project root onsys.pathandfrom server import mcpresolves.
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 := helpmakes baremakeprint the target list.UV := $(shell command -v uv)andDIR := $(shell pwd)matter because Claude Code launches a stdio server without your shell’sPATHor working directory. A bareuvin the recipe produces a server that connects from your terminal and fails from the client.TOOLSbuilds the fully-qualified tool names Claude Code uses for an MCP server,mcp__<server>__<tool>. Passing them to--allowedToolslets the Step 8 run proceed without a permission prompt per call.RELEASE ?= 2.4.0lets you runmake workflow RELEASE=2.3.1against 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
modeargument 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 idbeats “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_notesalready 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
releaseshould 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__...fromclaude -p. Prompt slash commands are resolved in interactive sessions only. Use an interactiveclaude, or pipe the rendered prompt text in asmake workflowdoes.- The slash command does not appear after
make register. Checkmake showreports✔ Connected. Local-scope servers are tied to the directory you registered from, so startclaudein that directory. Restart the session after registering. - The server connects from the terminal but not from Claude Code. The recipe
must use an absolute
uvpath and--directory; the client launches it without your shellPATHor working directory. ValidationErroronMessage(role="system", ...). MCP prompt messages allow onlyuserandassistant. Put the plan in anassistantturn.messages[0] must be Message or str, got PromptMessage. Wrap each turn inMessagefromfastmcp.prompts.prompt.- Catching
PromptErrorin a test does not work. That is the server-side exception. A client seesMcpErrorfrommcp.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
releasefrom the data file (see Add Argument Completions to an MCP Server). - Give
render_notesa 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).