Skip to content

Commit 5908286

Browse files
committed
docs: rich prompt content and runtime prompt changes on the prompts page
Two new sections on the prompts page. "More than text" shows a prompt embedding the team style guide as an EmbeddedResource (registered as a resource so the URI resolves) and attaching an image with UserMessage(Image(...)), plus the paste-vs-embed judgment call and the bare-content return form. "Changing the list at runtime" registers a user-defined prompt from a tool with add_prompt(Prompt.from_function(...)) and announces it with ctx.notify_prompts_changed() and ctx.session.send_prompt_list_changed(), with the listen frame, the legacy frame and the duplicate-name behaviour. tutorial002 now imports the message classes from mcp.server.mcpserver like the new examples. No-Verification-Needed: docs, docs_src examples and tests only
1 parent 53ca740 commit 5908286

6 files changed

Lines changed: 690 additions & 5 deletions

File tree

‎docs/servers/prompts.md‎

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Return a list of messages instead of a `str`:
8585
--8<-- "docs_src/prompts/tutorial002.py"
8686
```
8787

88-
* `UserMessage` and `AssistantMessage` come from `mcp.server.mcpserver.prompts.base`. Hand them a `str` and they wrap it in `TextContent` for you. The role is the class name.
88+
* `UserMessage` and `AssistantMessage` come from `mcp.server.mcpserver`. Hand them a `str` and they wrap it in `TextContent` for you. The role is the class name.
8989
* `Message` is their common base. Use it as the return annotation.
9090

9191
Rendering `debug_error` now produces three messages, in order:
@@ -134,17 +134,172 @@ The `prompts/list` entry now carries everything a client needs to draw a good fo
134134
```
135135

136136
!!! info
137-
If you have read **[Tools](tools.md)**, you already know everything on this page. Same decorator, same
137+
If you have read **[Tools](tools.md)**, you already know everything up to this point. Same decorator, same
138138
docstring-as-description, same `Annotated`/`Field`. The only things that change are who
139139
triggers it (the user) and where the result goes (into the conversation).
140140

141+
## More than text
142+
143+
A prompt message does not have to be text. Wherever `UserMessage` and `AssistantMessage` (the message classes from `mcp.server.mcpserver`) take a `str`, they also take a **content block**: any of the kinds a tool result can carry, which **[Images, audio & icons](media.md)** shows a tool producing one by one. This section uses two: an **embedded resource**, which carries a whole document along with its URI and MIME type, and an **image**.
144+
145+
### Embedding a file
146+
147+
A code review goes better when the reviewer has the team's style guide open. The Code Helper team keeps theirs in `style-guide.md`, next to `server.py`. Hand it to the model as a message of its own, ahead of the code:
148+
149+
```python title="server.py" hl_lines="5 12 21 23"
150+
--8<-- "docs_src/prompts/tutorial004.py"
151+
```
152+
153+
* `@mcp.resource(...)` publishes the file at `style://python`, the way **[Resources](resources.md)** describes. Embedding does not depend on this line, but with it the URI inside the message is one a client can actually read back.
154+
* `TextResourceContents` is what a client receives when it reads a text resource: a `uri`, a `mime_type`, and the `text`. Here the `text` comes from calling `style_guide()` directly (the decorator hands your function back unchanged), so the resource and the prompt serve one file from one place, read fresh on every render.
155+
* `EmbeddedResource(resource=...)` is the content block that carries the document, and `UserMessage` takes it in place of a `str`. A block has no role of its own; the message supplies one. `EmbeddedResource` and `TextResourceContents` are protocol types from `mcp.types`.
156+
157+
Put a `style-guide.md` beside `server.py` (two bullet points of Markdown will do), then render `review_code`. The guide arrives as the first message and the request that refers to it as the second:
158+
159+
```json
160+
{
161+
"description": "Review a piece of code against the team style guide.",
162+
"messages": [
163+
{
164+
"role": "user",
165+
"content": {
166+
"type": "resource",
167+
"resource": {
168+
"uri": "style://python",
169+
"mimeType": "text/markdown",
170+
"text": "* Prefer early returns.\n* No bare `except:`.\n"
171+
}
172+
}
173+
},
174+
{
175+
"role": "user",
176+
"content": {
177+
"type": "text",
178+
"text": "Review this code against the style guide above:\n\ndef add(a, b): return a + b"
179+
}
180+
}
181+
],
182+
"resultType": "complete"
183+
}
184+
```
185+
186+
You could paste the guide into the second message's f-string and the model would read the same words. Embedding it tells the client that this message is a document, with a URI and a format, so the client can show it as an attachment rather than a screenful of prose in the user's turn, and can read `style://python` again later. The model receives the file verbatim rather than folded into a sentence, and because the copy travels inside the message there is no `resources/read` round trip. Keep instructions in strings; send reference material (a style guide, a schema, the file under review) as an embedded resource.
187+
188+
A binary document embeds the same way with `BlobResourceContents` (also in `mcp.types`) in place of `TextResourceContents`: base64-encode the bytes into its `blob` field with `base64.b64encode(data).decode()` and give it the real `mime_type` (`application/pdf`, say). The spec requires a URI and a MIME type on every embedded resource; the SDK types do not enforce `mime_type`, so setting it is yours to remember. For a picture the model should look at, an image block (below) is the better fit.
189+
190+
!!! info
191+
An embedded resource is a copy. When the document is large, or the client may not want it
192+
at all, send a pointer instead: `ResourceLink(name="style_guide", uri="style://python",
193+
mime_type="text/markdown")`, also from `mcp.types`, goes into a `UserMessage` the same way.
194+
Nothing is embedded; the client reads the URI with `resources/read` if and when it wants the
195+
contents, which only works when the URI really is one of your resources.
196+
197+
### Attaching an image
198+
199+
A message can also carry a picture, as an `ImageContent` block, for a model that accepts images. The Code Helper project keeps its architecture diagram beside the code as `architecture.png`:
200+
201+
```python title="server.py" hl_lines="4 15"
202+
--8<-- "docs_src/prompts/tutorial005.py"
203+
```
204+
205+
* `Image` is the helper from **[Images, audio & icons](media.md)**. Give it `path=` and the MIME type is guessed from the suffix (`.png` is announced as `image/png`); for bytes already in memory, give it `data=` plus an explicit `format=`.
206+
* `UserMessage` converts it: the file is read and base64-encoded when the prompt renders, and the message carries the protocol's `ImageContent`. `Audio(...)` becomes an `AudioContent` the same way.
207+
* `component` is an ordinary prompt argument, and prompt arguments are always strings. A user cannot pass a picture in through one, so the image is something the server already has (a file on disk, a chart it just rendered) and the argument only supplies the words.
208+
209+
Any PNG named `architecture.png` beside `server.py` works. The client receives the diagram, then the question:
210+
211+
```json
212+
{
213+
"description": "Explain one component using the architecture diagram.",
214+
"messages": [
215+
{
216+
"role": "user",
217+
"content": {"type": "image", "data": "iVBORw0KGgoAAAANSUhEUg...", "mimeType": "image/png"}
218+
},
219+
{
220+
"role": "user",
221+
"content": {
222+
"type": "text",
223+
"text": "Where does the payment gateway sit in this architecture, and what does it talk to?"
224+
}
225+
}
226+
],
227+
"resultType": "complete"
228+
}
229+
```
230+
231+
`data` is the whole file, base64-encoded (cut short here).
232+
233+
!!! tip
234+
When every message is from the user, you can skip the wrappers: a prompt function may return
235+
an `Image`, a content block, or a list mixing them with strings, and each item becomes one
236+
user message, in order. `return [Image(path=DIAGRAM_FILE), f"Where does {component} sit..."]`
237+
renders exactly the two messages above. You only need `UserMessage` / `AssistantMessage`
238+
when one of the messages is an `assistant` turn.
239+
240+
A client needs nothing extra to receive these. In the Python `Client`, the `content` of each message in `get_prompt(...)`'s `messages` is typed as the same content-block union a tool result uses (`TextContent`, `ImageContent`, `AudioContent`, `ResourceLink`, `EmbeddedResource`). Narrow with `isinstance(block, EmbeddedResource)` or `isinstance(block, ImageContent)` before touching `.resource` or `.data`, as **[The Client](../client/index.md)** does with tool results.
241+
242+
## Changing the list at runtime
243+
244+
A decorated prompt exists from the moment the module is imported. The menu can also change while clients are connected: a user who keeps typing the same instruction ("explain this, line by line") wants it as an entry of its own, next to `review_code`.
245+
246+
Saving one is something the model does when the user asks, which makes it a tool. The tool does two things: register a prompt while the server is running, then tell connected clients that the list changed.
247+
248+
```python title="server.py" hl_lines="3 21-23"
249+
--8<-- "docs_src/prompts/tutorial006.py"
250+
```
251+
252+
* `mcp.add_prompt(...)` takes a `Prompt` object, not a bare function. `Prompt.from_function` builds one from `template` exactly as `@mcp.prompt()` would, arguments included, so the new entry gets one required `code` argument. Pass `name=` and `description=`: left to default they come from the function, and every function this tool defines is called `template` and has no docstring.
253+
* `ctx` is the parameter annotated `Context`. The SDK injects it and the model never sees it: `save_template`'s input schema has two properties, `name` and `instruction`. **[The Context](../handlers/context.md)** is the page about that object.
254+
* `await ctx.notify_prompts_changed()` publishes the change to every `2026-07-28` client whose `subscriptions/listen` stream asked for prompt-list changes. With no such stream open it is a no-op, so there is nothing to check before calling it. **[Subscriptions](../handlers/subscriptions.md)** under *Inside your handler* is the page on those streams.
255+
* `await ctx.session.send_prompt_list_changed()` sends the same notification to the client that made this call, if it is a pre-2026 client, down the stream its session keeps open. Other pre-2026 sessions are not told; they see the change on their next `prompts/list`. Neither line reaches the other era's clients, and neither raises when there is nobody to tell, so call both. **[Serving legacy clients](../run/legacy-clients.md)** explains the split.
256+
257+
Call `save_template` with `name="explain"` and `instruction="Explain what this code does, line by line."`. `prompts/list` includes `explain` the moment `add_prompt` returns; the notification exists only to tell clients that are already connected to ask again. On a `subscriptions/listen` stream it is one frame:
258+
259+
```json
260+
{"method": "notifications/prompts/list_changed",
261+
"params": {"_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
262+
```
263+
264+
It names no prompt, only the stream it arrived on. A pre-2026 client receives the bare `{"method": "notifications/prompts/list_changed"}` on its session's stream instead. Either way the client calls `prompts/list` again and finds the new entry beside `review_code`:
265+
266+
```json
267+
{
268+
"name": "explain",
269+
"description": "Explain what this code does, line by line.",
270+
"arguments": [
271+
{"name": "code", "required": true}
272+
]
273+
}
274+
```
275+
276+
In the Python `Client`, the `subscriptions/listen` stream is `async with client.listen(prompts_list_changed=True) as sub:` and each change arrives as a `PromptsListChanged` event; **[Subscriptions](../client/subscriptions.md)** under *Clients* covers that end.
277+
278+
!!! check
279+
Save `explain` a second time with different wording. The tool answers
280+
`Saved 'explain' to the prompt menu.` again, `prompts/list` still shows the first description,
281+
`prompts/get` still renders the first text, and the server logs one warning:
282+
283+
```text
284+
Prompt already exists: explain
285+
```
286+
287+
`add_prompt` keeps the entry it has and ignores the new one. `mcp.remove_prompt(name)` is its
288+
reverse and wants the same two notification calls after it; unlike `add_prompt` it raises
289+
`ValueError` for a name it does not have. Replacing a template is remove (if it exists), then
290+
add, then notify.
291+
292+
Publish when the *menu* changes: an entry added, removed or replaced. When only the text behind an existing entry changes there is nothing to announce, because `prompts/get` renders it fresh every time.
293+
141294
## Recap
142295

143296
* `@mcp.prompt()` on a function makes it a prompt. Name from the function, description from the docstring.
144297
* Prompts are **user-controlled**: the client lists them, the user picks one and fills in the arguments.
145298
* Arguments are a flat list of named strings (no schema). A parameter with a default is optional.
146299
* Return a `str` and it becomes one user message. Return a list of `UserMessage` / `AssistantMessage` to seed a multi-turn conversation.
147300
* `title=` and `Field(description=...)` are what a client puts in its UI.
301+
* A message is not limited to text. Wrap an `EmbeddedResource` in a `UserMessage` to attach a document with its URI and MIME type, or an `Image` to attach a picture.
148302
* A missing required argument fails the whole request. There is no per-prompt error result.
303+
* The menu can change while clients are connected: `mcp.add_prompt(Prompt.from_function(...))` or `mcp.remove_prompt(name)`, then `await ctx.notify_prompts_changed()` for listening `2026-07-28` clients and `await ctx.session.send_prompt_list_changed()` for a pre-2026 caller.
149304

150305
Server-side autocomplete for a prompt's (or a resource template's) arguments is **[Completions](completions.md)**.

‎docs_src/prompts/tutorial002.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from mcp.server import MCPServer
2-
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage
2+
from mcp.server.mcpserver import AssistantMessage, Message, UserMessage
33

44
mcp = MCPServer("Code Helper")
55

‎docs_src/prompts/tutorial004.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from pathlib import Path
2+
3+
from mcp.server import MCPServer
4+
from mcp.server.mcpserver import Message, UserMessage
5+
from mcp.types import EmbeddedResource, TextResourceContents
6+
7+
mcp = MCPServer("Code Helper")
8+
9+
STYLE_GUIDE_FILE = Path(__file__).parent / "style-guide.md" # or the path to your file on disk
10+
11+
12+
@mcp.resource("style://python", mime_type="text/markdown")
13+
def style_guide() -> str:
14+
"""The team's Python style guide."""
15+
return STYLE_GUIDE_FILE.read_text(encoding="utf-8")
16+
17+
18+
@mcp.prompt()
19+
def review_code(code: str) -> list[Message]:
20+
"""Review a piece of code against the team style guide."""
21+
guide = TextResourceContents(uri="style://python", mime_type="text/markdown", text=style_guide())
22+
return [
23+
UserMessage(EmbeddedResource(resource=guide)),
24+
UserMessage(f"Review this code against the style guide above:\n\n{code}"),
25+
]

‎docs_src/prompts/tutorial005.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from pathlib import Path
2+
3+
from mcp.server import MCPServer
4+
from mcp.server.mcpserver import Image, Message, UserMessage
5+
6+
mcp = MCPServer("Code Helper")
7+
8+
DIAGRAM_FILE = Path(__file__).parent / "architecture.png" # or the path to your file on disk
9+
10+
11+
@mcp.prompt()
12+
def explain_component(component: str) -> list[Message]:
13+
"""Explain one component using the architecture diagram."""
14+
return [
15+
UserMessage(Image(path=DIAGRAM_FILE)),
16+
UserMessage(f"Where does {component} sit in this architecture, and what does it talk to?"),
17+
]

‎docs_src/prompts/tutorial006.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from mcp.server import MCPServer
2+
from mcp.server.mcpserver import Context
3+
from mcp.server.mcpserver.prompts import Prompt
4+
5+
mcp = MCPServer("Code Helper")
6+
7+
8+
@mcp.prompt()
9+
def review_code(code: str) -> str:
10+
"""Review a piece of code."""
11+
return f"Please review this code:\n\n{code}"
12+
13+
14+
@mcp.tool()
15+
async def save_template(name: str, instruction: str, ctx: Context) -> str:
16+
"""Save an instruction as a prompt the user can pick from the menu."""
17+
18+
def template(code: str) -> str:
19+
return f"{instruction}\n\n{code}"
20+
21+
mcp.add_prompt(Prompt.from_function(template, name=name, description=instruction))
22+
await ctx.notify_prompts_changed()
23+
await ctx.session.send_prompt_list_changed()
24+
return f"Saved '{name}' to the prompt menu."

0 commit comments

Comments
 (0)