Tools let a model do things; prompts let a user start things. An MCP prompt is a named, parameterized message template a client surfaces as a slash command or a menu entry — the user picks it, fills in a couple of arguments, and the client drops a ready-made conversation into the model. Most tutorials define one prompt in passing and move on. This one treats prompts as the subject.
You will build a small prompt-library server with FastMCP
that shows the three things that make a prompt more than an f-string: arguments
(required and optional), multi-message templates that stage a conversation,
and an embedded resource so a prompt can carry live context inline. The stack
is Mac-native: uv, make, and pytest.
What a prompt is
A prompt is a server-defined function that returns one or more chat messages. A
client lists prompts, shows their arguments, and calls prompts/get with the
user’s values to render the messages. Two facts shape the design:
- A prompt returns messages, not a system instruction. MCP prompt messages
use only the
userandassistantroles. There is nosystemrole; framing that you would put in a system prompt goes in anassistantmessage or the firstusermessage. - Messages can hold more than text. A message’s content can be text, an image, or an embedded resource, so a prompt can ship a document inline instead of telling the user to paste it.
What you will build
server.py: aguide://styleresource plus three prompts —summarize(an optional argument),code_review(a two-turn template), andrewrite_with_guide(an embedded resource).client.py: an in-memory client that lists the prompts and renders each one.- A
pytestsuite covering argument requiredness, the default path, roles, and the embedded-resource message. - A
makewrapper with a help screen.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - Familiarity with FastMCP and the in-memory
Client(see Build an MCP Server with FastMCP).
Step 1: Add project hygiene
Create the file
mkdir -p mcp-prompts-macos
cd mcp-prompts-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.log
# OS / editor noise
.DS_Store
Detailed breakdown
- Standard Python ignores plus
.DS_Store, created first so the virtualenv and caches never get committed.
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 = "An MCP prompt library: arguments, templates, and embedded resources"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.4",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
fastmcpis the only runtime dependency.Messagecomes fromfastmcp.prompts.prompt; theEmbeddedResourceandTextResourceContentstypes come from themcppackage it installs.
Step 3: The server and its three prompts
The server defines one resource and three prompts, each demonstrating one idea: an optional argument, a multi-turn template, and an embedded resource.
Create the file
touch server.py
Add the code: server.py
"""A prompt-library MCP server for a writing team.
Prompts are reusable, parameterized message templates a client can surface to a
user (a slash command, a "prompt" menu). This server shows the three things that
make prompts more than a string:
- **Arguments**, required and optional, declared by the function signature.
- **Multi-message templates** that set up a conversation (an assistant framing
turn plus the user's turn). MCP prompt messages use only the `user` and
`assistant` roles — there is no `system` role.
- **Embedded resources**, so a prompt can carry live context (the team style
guide) inline instead of pasting it.
Run over stdio with `python server.py`, or drive it with client.py.
"""
from fastmcp import FastMCP
from fastmcp.prompts.prompt import Message
from mcp.types import EmbeddedResource, TextResourceContents
mcp = FastMCP("prompt-library")
STYLE_GUIDE = "Use active voice. Prefer short sentences. Avoid jargon."
@mcp.resource("guide://style")
def style_guide() -> str:
"""The team writing style guide, also embeddable in a prompt."""
return STYLE_GUIDE
@mcp.prompt
def summarize(text: str, style: str = "concise") -> str:
"""Summarize text. `style` is optional and defaults to "concise"."""
return f"Summarize the following in a {style} style:\n\n{text}"
@mcp.prompt
def code_review(language: str, code: str) -> list[Message]:
"""A two-turn review prompt: an assistant framing turn, then the user's code."""
return [
Message(
role="assistant",
content="You are a meticulous senior code reviewer. "
"Focus on correctness first, then clarity.",
),
Message(
role="user",
content=f"Review this {language} code and list concrete issues:\n\n{code}",
),
]
@mcp.prompt
def rewrite_with_guide(text: str) -> list[Message]:
"""Rewrite `text`, carrying the style guide inline as an embedded resource."""
guide = EmbeddedResource(
type="resource",
resource=TextResourceContents(
uri="guide://style", text=STYLE_GUIDE, mimeType="text/plain"
),
)
return [
Message(role="user", content="Rewrite the text below to follow the attached style guide."),
Message(role="user", content=guide),
Message(role="user", content=text),
]
if __name__ == "__main__":
mcp.run()
Detailed breakdown
- Arguments come from the signature.
summarize(text, style="concise")gives a requiredtext(no default) and an optionalstyle(has a default). FastMCP reports that requiredness inprompts/list, so a client knows which fields to make mandatory. The docstring becomes the prompt’s description. - A string return is one user message.
summarizereturns a plain string; FastMCP wraps it in a singleusermessage. That covers the common case with no ceremony. code_reviewreturns a list ofMessage. EachMessage(role=..., content=...)is one turn. Theassistantturn frames the task (the closest MCP has to a system prompt), and theuserturn carries the code. UseMessagefromfastmcp.prompts.prompt, not the rawPromptMessagetype — FastMCP rejects the unwrapped form.rewrite_with_guideembeds a resource. AnEmbeddedResourcewrapsTextResourceContentswith the sameguide://styleURI the resource serves, so the prompt ships the style guide inline. A client receives it as a resource content block it can render or feed to the model, not as pasted text it has to trust.STYLE_GUIDEis defined once and used by both the resource and the embedded copy, so they cannot drift apart.
Step 4: Render the prompts from a client
Create the file
touch client.py
Add the code: client.py
"""Drive the prompt-library server in-memory.
Lists the prompts with their arguments, then renders each one to show a
single-message prompt, a multi-turn template, and a prompt that embeds a
resource inline.
"""
import asyncio
from fastmcp import Client
from server import mcp
def show(messages) -> None:
for m in messages:
if m.content.type == "resource":
r = m.content.resource
print(f" [{m.role}] <resource {r.uri}> {r.text}")
else:
print(f" [{m.role}] {m.content.text!r}")
async def main() -> None:
async with Client(mcp) as client:
print("Prompts:")
for p in await client.list_prompts():
args = ", ".join(
f"{a.name}{'' if a.required else '?'}" for a in (p.arguments or [])
)
print(f" {p.name}({args})")
print("\nsummarize (default style):")
r = await client.get_prompt("summarize", {"text": "Q3 revenue rose 4%."})
show(r.messages)
print("\nsummarize (style='bullet points'):")
r = await client.get_prompt(
"summarize", {"text": "Q3 revenue rose 4%.", "style": "bullet points"}
)
show(r.messages)
print("\ncode_review:")
r = await client.get_prompt(
"code_review", {"language": "python", "code": "def add(a, b): return a - b"}
)
show(r.messages)
print("\nrewrite_with_guide (embeds guide://style):")
r = await client.get_prompt(
"rewrite_with_guide", {"text": "The report was written by the team."}
)
show(r.messages)
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
list_promptsreturns each prompt with itsarguments; the?suffix marks the optional ones so the printout reads like a signature.get_prompt(name, values)renders the template. The result’smessagesis the conversation a client would insert.showbranches onm.content.type: aresourcecontent block exposesm.content.resource(with.uriand.text), everything else hasm.content.text.
Run it:
uv run python client.py
Expected output:
Prompts:
summarize(text, style?)
code_review(language, code)
rewrite_with_guide(text)
summarize (default style):
[user] 'Summarize the following in a concise style:\n\nQ3 revenue rose 4%.'
summarize (style='bullet points'):
[user] 'Summarize the following in a bullet points style:\n\nQ3 revenue rose 4%.'
code_review:
[assistant] 'You are a meticulous senior code reviewer. Focus on correctness first, then clarity.'
[user] 'Review this python code and list concrete issues:\n\ndef add(a, b): return a - b'
rewrite_with_guide (embeds guide://style):
[user] 'Rewrite the text below to follow the attached style guide.'
[user] <resource guide://style> Use active voice. Prefer short sentences. Avoid jargon.
[user] 'The report was written by the team.'
Step 5: Test the prompts
Create the files
mkdir -p tests
touch tests/__init__.py
touch pytest.ini
touch tests/test_server.py
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
Detailed breakdown
asyncio_mode = autoruns the async tests without a marker.tests/__init__.pymakestests/a package so the project root lands onsys.pathandfrom server import mcpresolves.
Add the code: tests/test_server.py
"""Tests for the prompt-library server.
Covers argument declaration (required vs optional), the default-argument path, a
multi-turn template with assistant/user roles, an embedded-resource message, and
the missing-required-argument error.
"""
import pytest
from fastmcp import Client
from mcp.shared.exceptions import McpError
from server import mcp
async def test_prompts_and_argument_requiredness():
async with Client(mcp) as client:
prompts = {p.name: p for p in await client.list_prompts()}
args = {a.name: a.required for a in prompts["summarize"].arguments}
assert args == {"text": True, "style": False} # style is optional
async def test_optional_argument_defaults():
async with Client(mcp) as client:
r = await client.get_prompt("summarize", {"text": "hi"})
assert r.messages[0].role == "user"
assert "in a concise style" in r.messages[0].content.text
async def test_optional_argument_override():
async with Client(mcp) as client:
r = await client.get_prompt("summarize", {"text": "hi", "style": "bullet points"})
assert "in a bullet points style" in r.messages[0].content.text
async def test_multi_message_roles():
async with Client(mcp) as client:
r = await client.get_prompt("code_review", {"language": "go", "code": "x := 1"})
assert [m.role for m in r.messages] == ["assistant", "user"]
assert "senior code reviewer" in r.messages[0].content.text
assert "Review this go code" in r.messages[1].content.text
async def test_embedded_resource_message():
async with Client(mcp) as client:
r = await client.get_prompt("rewrite_with_guide", {"text": "make this better"})
# The middle message carries the style guide as an embedded resource.
embedded = r.messages[1].content
assert embedded.type == "resource"
assert str(embedded.resource.uri) == "guide://style"
assert "active voice" in embedded.resource.text
# The user's text is the last message.
assert r.messages[2].content.text == "make this better"
async def test_missing_required_argument_errors():
async with Client(mcp) as client:
with pytest.raises(McpError):
await client.get_prompt("summarize", {}) # text is required
Detailed breakdown
test_prompts_and_argument_requirednesspins the contract a client relies on:textrequired,styleoptional.test_multi_message_roleschecks the turn order and roles of the template.test_embedded_resource_messageconfirms the middle message is a resource block with the right URI and text, and that the user’s text follows it.test_missing_required_argument_errorsshows the server rejects a render that omits a required argument, rather than producing a half-filled template.
Step 6: Wrap it in a Makefile
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help install demo serve test clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
install: ## Sync runtime and dev dependencies
uv sync
demo: ## Render every prompt and print its messages
uv run python client.py
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
clean: ## Remove caches
rm -rf .pytest_cache __pycache__ tests/__pycache__
Detailed breakdown
.DEFAULT_GOAL := helpmakes a baremakeprint the help screen. Recipe bodies must be tab-indented ormakeerrors.
Step 7: Run everything
make # prints the help screen
make demo # renders the prompts (output shown in Step 4)
make test # runs the suite
The suite passes:
...... [100%]
6 passed in 0.30s
When to use a prompt
- Prompt vs tool. A tool is called by the model to take an action and return data; a prompt is chosen by the user to seed a conversation. If the model should decide when to run it, make it a tool. If a person wants a repeatable starting point (“review this code,” “summarize this”), make it a prompt.
- Keep arguments few and typed. Prompts are filled in by hand. One or two clear arguments with sensible defaults beat a form with eight fields.
- Embed context instead of pasting it. When a prompt needs a document the server already has, embed the resource. The content stays addressable by URI and does not depend on the user copying the current version.
- Frame with an assistant turn. With no
systemrole available, put task framing in a leadingassistantmessage and the user’s material in theuserturns that follow.
Troubleshooting
messages[0] must be Message or str, got PromptMessage. You returned the rawPromptMessagetype. Wrap each turn inMessagefromfastmcp.prompts.prompt(or return a plain string for a single user message).ValidationErroronMessage(role="system", ...). MCP prompt messages only allowuserandassistant. Move system-style framing into anassistantmessage.Missing required arguments. A required argument (one with no default) was omitted fromget_prompt. Give it a default in the signature to make it optional, or supply it.- The embedded resource shows as text, not a resource. The content must be an
EmbeddedResource, and the message must be aMessagewrapping it. A bare dict or string becomes a text block. ModuleNotFoundError: No module named 'server'under pytest.tests/needs an__init__.pyso pytest puts the project root onsys.path.
Recap
Prompts are the user-facing half of an MCP server. This one used the function
signature to declare required and optional arguments, returned a single user
message for the simple case and a multi-turn assistant/user template for the
staged one, and embedded a resource so a prompt could carry the style guide inline
by URI. A client lists these, shows their arguments, and renders them on demand.
Next improvements:
- Add argument completions so a client autocompletes a prompt’s arguments (see Add Argument Completions to an MCP Server).
- Return an
ImageContentmessage for a prompt that stages a vision task. - Load prompt text from files so non-engineers can edit the templates.