Skip to content

Commit 4d2f50e

Browse files
committed
docs: load media examples from disk instead of inline base64
The Image/Audio tutorials fed the helpers base64-decoded one-pixel placeholders, so the first thing a reader met was an opaque blob. Point them at logo.png / chime.wav beside the server instead, anchored to the script's own directory so hosts that launch stdio servers from an arbitrary working directory still resolve them. The page teaches path= first and keeps data= + format= for bytes that never touch disk. Tests fabricate the assets in tmp_path and repoint the module constants, so no binary lands in the repo.
1 parent 497f7af commit 4d2f50e

4 files changed

Lines changed: 66 additions & 32 deletions

File tree

docs/servers/media.md

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,18 @@ The SDK ships two helpers for binary results (**`Image`** and **`Audio`**) and a
66

77
## Returning an image
88

9-
Annotate the return type as `Image` and return one:
9+
Annotate the return type as `Image`, point it at a file, and return it:
1010

11-
```python title="server.py" hl_lines="14 16"
11+
```python title="server.py" hl_lines="8 12 14"
1212
--8<-- "docs_src/media/tutorial001.py"
1313
```
1414

15-
* `Image` takes exactly one of `data` (raw bytes) or `path` (a file to read).
16-
* `format="png"` becomes the MIME type the client sees: `image/png`.
17-
* The bytes here are a one-pixel placeholder so the file runs on its own. In a real server they come from Pillow, matplotlib, a headless browser, or anything else that hands you `bytes`.
15+
* `Image` takes exactly one of `path` (a file to read) or `data` (raw bytes).
16+
* The MIME type the client sees is guessed from the suffix: `logo.png` is announced as `image/png`.
17+
* Anchoring to `Path(__file__).parent` matters. Hosts launch stdio servers from an arbitrary working directory, so a bare `"logo.png"` would resolve against wherever that happens to be.
18+
* Nothing here is special about logos. Any PNG next to `server.py` works: a chart your code rendered, a diagram, a photo.
1819

19-
`Image` is an SDK convenience, not a protocol type. On the wire your return value becomes an **`ImageContent`** block (your bytes base64-encoded, plus the MIME type):
20+
`Image` is an SDK convenience, not a protocol type. On the wire your return value becomes an **`ImageContent`** block (the file's bytes base64-encoded, plus the MIME type):
2021

2122
```python
2223
result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")]
@@ -25,7 +26,7 @@ result.structured_content # None
2526

2627
Two things to notice:
2728

28-
* `data` is base64. You returned raw `bytes`; the SDK did the encoding.
29+
* `data` is base64. You never touched the bytes; the SDK read the file and did the encoding.
2930
* `structured_content` is `None`. An `Image` is content for the model to look at, not data for the application to parse: there is no output schema. (Contrast **[Structured Output](structured-output.md)**, where the return annotation *is* the schema.)
3031

3132
!!! info
@@ -35,32 +36,41 @@ Two things to notice:
3536

3637
### Try it
3738

39+
Drop any PNG next to `server.py`, name it `logo.png`, and run:
40+
3841
```console
3942
uv run mcp dev server.py
4043
```
4144

42-
Open the **Tools** tab and call `logo`. The result is not a string: it is an `image` content block, and the Inspector renders it as a picture. You returned `bytes`; everything between that and the pixels on screen was the SDK.
45+
Open the **Tools** tab and call `logo`. The result is not a string: it is an `image` content block, and the Inspector renders your picture. Everything between the file on disk and the pixels on screen was the SDK.
4346

4447
## Returning audio
4548

46-
`Audio` is the same shape:
49+
`Audio` is the same shape. Keep `logo.png` where it was, and put any WAV beside it as `chime.wav`:
4750

48-
```python title="server.py" hl_lines="21-24"
51+
```python title="server.py" hl_lines="18-21"
4952
--8<-- "docs_src/media/tutorial002.py"
5053
```
5154

5255
The result is an **`AudioContent`** block:
5356

5457
```python
55-
result.content # [AudioContent(type="audio", data="UklGRjQAAABXQVZFZm1...", mime_type="audio/wav")]
58+
result.content # [AudioContent(type="audio", data="UklGR...", mime_type="audio/wav")]
5659
result.structured_content # None
5760
```
5861

59-
Same deal: raw bytes in, base64 and a MIME type out, no output schema.
62+
Same deal: a file on disk in, base64 and a MIME type out, no output schema.
6063

6164
## Bytes or a file
6265

63-
Both helpers also accept `path=` instead of `data=`. The file is read when the result is built, and the MIME type is guessed from the suffix:
66+
Both helpers also accept `data=` (raw bytes) instead of `path=`. That is the mode for bytes that never touch disk — Pillow just drew the chart, a headless browser just took the screenshot:
67+
68+
```python
69+
Image(data=chart_png, format="png") # image/png
70+
Audio(data=chime_wav, format="wav") # audio/wav
71+
```
72+
73+
With `path=` there is nothing to declare: the file is read when the result is built, and the MIME type is guessed from the suffix:
6474

6575
* `Image`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`.
6676
* `Audio`: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`.
@@ -100,7 +110,7 @@ A tool's icons are on the `Tool` object from `tools/list`, a resource's on the `
100110
## Recap
101111

102112
* Return an `Image` or `Audio` from a tool and the client receives an `ImageContent` / `AudioContent` block: your bytes base64-encoded, with a MIME type.
103-
* Build one from in-memory `data=` plus an explicit `format=`, or from a `path=` and let the suffix decide.
113+
* Build one from a `path=` and let the suffix decide the MIME type, or from in-memory `data=` plus an explicit `format=`.
104114
* Media results carry no `structured_content` and no output schema.
105115
* An `Icon` is a pointer: a `src` URI plus optional `mime_type`, `sizes`, and `theme`.
106116
* `icons=[...]` works on the server, on tools, on resources, and on prompts, and clients find them on the matching objects.

docs_src/media/tutorial001.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
1-
import base64
1+
from pathlib import Path
22

33
from mcp.server import MCPServer
44
from mcp.server.mcpserver import Image
55

66
mcp = MCPServer("Brand kit")
77

8-
LOGO_PNG = base64.b64decode(
9-
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOQ9bsBAAHPAURf8l/aAAAAAElFTkSuQmCC"
10-
)
8+
LOGO_FILE = Path(__file__).parent / "logo.png"
119

1210

1311
@mcp.tool()
1412
def logo() -> Image:
1513
"""The brand logo as a PNG."""
16-
return Image(data=LOGO_PNG, format="png")
14+
return Image(path=LOGO_FILE)

docs_src/media/tutorial002.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
1-
import base64
1+
from pathlib import Path
22

33
from mcp.server import MCPServer
44
from mcp.server.mcpserver import Audio, Image
55

66
mcp = MCPServer("Brand kit")
77

8-
LOGO_PNG = base64.b64decode(
9-
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOQ9bsBAAHPAURf8l/aAAAAAElFTkSuQmCC"
10-
)
11-
12-
CHIME_WAV = base64.b64decode("UklGRjQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YRAAAAAAAAAAAAAAAAAAAAAAAAAA")
8+
LOGO_FILE = Path(__file__).parent / "logo.png"
9+
CHIME_FILE = Path(__file__).parent / "chime.wav"
1310

1411

1512
@mcp.tool()
1613
def logo() -> Image:
1714
"""The brand logo as a PNG."""
18-
return Image(data=LOGO_PNG, format="png")
15+
return Image(path=LOGO_FILE)
1916

2017

2118
@mcp.tool()
2219
def chime() -> Audio:
2320
"""The notification chime as a WAV."""
24-
return Audio(data=CHIME_WAV, format="wav")
21+
return Audio(path=CHIME_FILE)

tests/docs_src/test_media.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""`docs/servers/media.md`: every claim the page makes, proved against the real SDK."""
22

33
import base64
4+
from pathlib import Path
45

56
import pytest
67
from mcp_types import AudioContent, Icon, ImageContent
@@ -13,16 +14,31 @@
1314
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
1415

1516

16-
async def test_image_return_becomes_an_image_content_block() -> None:
17-
"""tutorial001: `-> Image` reaches the client as a base64 `ImageContent` block, not text."""
17+
@pytest.fixture
18+
def logo_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
19+
"""The PNG the tutorials expect next to `server.py`, fabricated on disk.
20+
21+
The SDK never parses the contents, so opaque bytes suffice.
22+
"""
23+
path = tmp_path / "logo.png"
24+
path.write_bytes(b"fake png data")
25+
monkeypatch.setattr(tutorial001, "LOGO_FILE", path)
26+
monkeypatch.setattr(tutorial002, "LOGO_FILE", path)
27+
return path
28+
29+
30+
async def test_image_return_becomes_an_image_content_block(logo_file: Path) -> None:
31+
"""tutorial001: `-> Image` reaches the client as a base64 `ImageContent` block, not text,
32+
with the MIME type guessed from the `.png` suffix."""
1833
async with Client(tutorial001.mcp) as client:
1934
result = await client.call_tool("logo", {})
2035
assert not result.is_error
2136
assert result.content == [
22-
ImageContent(type="image", data=base64.b64encode(tutorial001.LOGO_PNG).decode(), mime_type="image/png")
37+
ImageContent(type="image", data=base64.b64encode(logo_file.read_bytes()).decode(), mime_type="image/png")
2338
]
2439

2540

41+
@pytest.mark.usefixtures("logo_file")
2642
async def test_image_result_has_no_structured_content_and_no_output_schema() -> None:
2743
"""tutorial001: media is content for the model, not data for the application."""
2844
async with Client(tutorial001.mcp) as client:
@@ -32,21 +48,34 @@ async def test_image_result_has_no_structured_content_and_no_output_schema() ->
3248
assert result.structured_content is None
3349

3450

35-
async def test_audio_return_becomes_an_audio_content_block() -> None:
51+
async def test_audio_return_becomes_an_audio_content_block(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
3652
"""tutorial002: `Audio` is the same shape as `Image`."""
53+
chime_file = tmp_path / "chime.wav"
54+
chime_file.write_bytes(b"fake wav data")
55+
monkeypatch.setattr(tutorial002, "CHIME_FILE", chime_file)
56+
3757
async with Client(tutorial002.mcp) as client:
3858
result = await client.call_tool("chime", {})
3959
assert not result.is_error
4060
assert result.content == [
41-
AudioContent(type="audio", data=base64.b64encode(tutorial002.CHIME_WAV).decode(), mime_type="audio/wav")
61+
AudioContent(type="audio", data=base64.b64encode(chime_file.read_bytes()).decode(), mime_type="audio/wav")
4262
]
4363
assert result.structured_content is None
4464

4565

66+
def test_path_file_is_read_when_the_result_is_built() -> None:
67+
"""The page's `path=` claim (SDK-defined): the file is opened when the result is built,
68+
not when the helper is constructed — `server.py` can name a file that appears later."""
69+
image = Image(path="does-not-exist.png")
70+
with pytest.raises(FileNotFoundError):
71+
image.to_image_content()
72+
73+
4674
def test_raw_data_without_a_format_falls_back_to_a_default_mime_type() -> None:
4775
"""The `!!! check`: with `data=` there is no suffix to guess from, so `format=` decides."""
4876
assert Image(data=b"\x89PNG\r\n\x1a\n", format="png").to_image_content().mime_type == "image/png"
4977
assert Image(data=b"\x89PNG\r\n\x1a\n").to_image_content().mime_type == "image/png"
78+
assert Audio(data=b"\xff\xfb", format="wav").to_audio_content().mime_type == "audio/wav"
5079
assert Audio(data=b"\xff\xfb").to_audio_content().mime_type == "audio/wav"
5180

5281

0 commit comments

Comments
 (0)