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 user and assistant roles. There is no system role; framing that you would put in a system prompt goes in an assistant message or the first user message.
  • 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: a guide://style resource plus three prompts — summarize (an optional argument), code_review (a two-turn template), and rewrite_with_guide (an embedded resource).
  • client.py: an in-memory client that lists the prompts and renders each one.
  • A pytest suite covering argument requiredness, the default path, roles, and the embedded-resource message.
  • A make wrapper with a help screen.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • 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

  • fastmcp is the only runtime dependency. Message comes from fastmcp.prompts.prompt; the EmbeddedResource and TextResourceContents types come from the mcp package 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 required text (no default) and an optional style (has a default). FastMCP reports that requiredness in prompts/list, so a client knows which fields to make mandatory. The docstring becomes the prompt’s description.
  • A string return is one user message. summarize returns a plain string; FastMCP wraps it in a single user message. That covers the common case with no ceremony.
  • code_review returns a list of Message. Each Message(role=..., content=...) is one turn. The assistant turn frames the task (the closest MCP has to a system prompt), and the user turn carries the code. Use Message from fastmcp.prompts.prompt, not the raw PromptMessage type — FastMCP rejects the unwrapped form.
  • rewrite_with_guide embeds a resource. An EmbeddedResource wraps TextResourceContents with the same guide://style URI 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_GUIDE is 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_prompts returns each prompt with its arguments; the ? suffix marks the optional ones so the printout reads like a signature.
  • get_prompt(name, values) renders the template. The result’s messages is the conversation a client would insert.
  • show branches on m.content.type: a resource content block exposes m.content.resource (with .uri and .text), everything else has m.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 = auto runs the async tests without a marker.
  • tests/__init__.py makes tests/ a package so the project root lands on sys.path and from server import mcp resolves.

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_requiredness pins the contract a client relies on: text required, style optional.
  • test_multi_message_roles checks the turn order and roles of the template.
  • test_embedded_resource_message confirms 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_errors shows 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 := help makes a bare make print the help screen. Recipe bodies must be tab-indented or make errors.

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 system role available, put task framing in a leading assistant message and the user’s material in the user turns that follow.

Troubleshooting

  • messages[0] must be Message or str, got PromptMessage. You returned the raw PromptMessage type. Wrap each turn in Message from fastmcp.prompts.prompt (or return a plain string for a single user message).
  • ValidationError on Message(role="system", ...). MCP prompt messages only allow user and assistant. Move system-style framing into an assistant message.
  • Missing required arguments. A required argument (one with no default) was omitted from get_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 a Message wrapping it. A bare dict or string becomes a text block.
  • ModuleNotFoundError: No module named 'server' under pytest. tests/ needs an __init__.py so pytest puts the project root on sys.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 ImageContent message for a prompt that stages a vision task.
  • Load prompt text from files so non-engineers can edit the templates.