A resource that returns a picture is the most demanding shape an MCP resource takes. It has to survive base64 encoding, arrive with a MIME type the client will accept, stay small enough to attach, and — if the picture is meant to reflect something live — return different bytes the next time the same URI is read.
Serve Resources Well from an MCP Server on macOS
covered the shapes of resources with an eight-byte PNG magic number standing in
for a binary body. This article replaces that stub with a real image: a bar chart
rendered on every read from state that anything can change, packaged so uvx
runs it from a local directory, and wired into opencode so
a model can read it, watch it change, and read it again.
Two findings from that wiring drive most of the design below. opencode 1.18.5 attaches a binary resource only if its MIME type is one of five, and FastMCP 3.4.4 drops the declared MIME type on resource templates — which turns a working image into one the client silently refuses. Both are demonstrated rather than asserted.
A third finding outranks both, and it is the one to read before you design anything around an image resource.
Read this first: only one client here draws the picture
Three clients were measured against this server. All three load the image. One of them puts it on your screen.
| Client | Gets the bytes | Draws the picture for you |
|---|---|---|
| MCP Inspector 1.0.0 | Yes — resources/read and tools/call both | Yes. Rendered in the Resources and Tools tabs |
| opencode 1.18.5 | Yes — attached to the model as image/png, byte-identical to the render | No. The terminal prints a tool-call line and a stored result reading [Binary MCP resource attached: …] |
| Claude Desktop 1.24012.9 | Yes, through the get_chart tool. The resource is not reachable from a prompt at all | No. A collapsed Get chart block, empty when expanded |
Nothing in that table is a server bug, and none of it is fixable from the server.
The bytes leave correctly every time: 465 bytes of image/png with SHA-256
1786bcf09b2b62e0…, the same digest from four independent paths. What each client
does next is its own decision, and two of these three decide to show a person
nothing.
The model is better served than you are. In Claude Desktop the ImageContent
block reaches the model intact — asked what colour the bar was, it answered
orange, which appears nowhere in any text this server sends (Step 14). In
opencode the attachment reaches the model too, and whether the model can use it
then depends on the model and the provider (Step 12). So three parties see this
image differently:
- The server sends it. Provable by test.
- The model receives it. Provable by asking a question only the pixels answer.
- The person reading along sees it in the MCP Inspector, and nowhere else here.
Two consequences run through the rest of this article. Build the server as if the
model is the audience, because that is the audience you can actually reach — which
is why Step 6 mirrors the two headline resources as tools. And when you need to
look at the picture, use the Inspector (Step 9) or open the exported PNG
(make open), not a chat window. “Ask a chat client to load my image and show it
to me” is not a workflow any client here supports today; the closing section
Why a chat client is a poor display path
covers what to do instead, and where ChatGPT sits as an open question.
What you will build
updating-image-mcp-resource-macos/
├── .gitignore
├── pyproject.toml # packaged: [project.scripts] is what uvx runs
├── Makefile # help / test / smoke / inspect / bump / config
├── opencode.json.example # project-scoped MCP entry, path templated
├── src/mcp_chart_server/
│ ├── __init__.py # console-script entry, with a --check smoke
│ ├── png.py # a PNG encoder in zlib + struct, no deps
│ ├── chart.py # canvas, 3x5 digit font, bar renderer
│ ├── state.py # file-backed revision + readings
│ └── server.py # the FastMCP resources and tools
├── scripts/
│ ├── smoke_test.py # stdio client: read, mutate, re-read, compare
│ └── chartctl.py # change the chart with no MCP client involved
└── tests/
├── test_png.py # encoder structure, CRCs, determinism
└── test_server.py # wire content: blob vs text, MIME, updates
The server ends up with four resources over one piece of state:
| URI | MIME | Body | Why it is here |
|---|---|---|---|
chart://latest.png | image/png | blob | The headline: the picture, redrawn per read |
chart://window/{count} | image/png | blob | A template, and the MIME-loss trap |
chart://latest.svg | image/svg+xml | text | What a client does with a format it will not attach |
chart://state | application/json | text | The numbers, so a reader can prove the bytes changed |
Plus four tools. Two of them mutate the state — add_reading and reset_chart —
and two, get_chart and get_chart_state, return the same bytes as the two
headline resources. That duplication is deliberate: a resource is fetched by the
client, a tool is called by the model, and some clients never give the model a
way to do the former. Step 6 covers why, Step 14 covers the client that forced it.
Prerequisites
- macOS. Validated on macOS 26.5.2 (Apple silicon). The server side works unchanged on Linux.
- uv 0.5 or later, which provides
uvx. Validated on uv 0.11.26. Install withbrew install uvor from docs.astral.sh/uv. - Python 3.12 or later.
uvwill fetch an interpreter if yours is older. - opencode 1.x for the client half. Validated on 1.18.5;
brew install opencode. Add an MCP Server to opencode on macOS covers the registration mechanics this article builds on. - make — preinstalled on macOS.
- Node 18 or later, for
npx— needed only by the MCP Inspector in Step 9 (make inspect,make resources). Nothing else in the project uses it. Validated with the Inspector at 1.0.0.
No image library. The PNG encoder is 40 lines of standard library, which keeps the package’s runtime dependency list at exactly one entry.
Step 1: Add project hygiene
Create the file
mkdir -p updating-image-mcp-resource-macos
cd updating-image-mcp-resource-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
venv/
# uv / build
.uv-cache/
dist/
*.egg-info/
# Test + tooling caches
.pytest_cache/
.ruff_cache/
# Generated chart state and exported images
chart-state.json
out/
# opencode local state
.opencode/
# macOS
.DS_Store
# Rendered by `make config` (contains an absolute path)
opencode.json
Detailed breakdown
- Written before anything else, so no generated artifact is ever tracked.
out/holds PNGs the smoke test exports;dist/holds wheels. Both are build output, not source.opencode.jsonis ignored here, unlike in the opencode article. The launch command for a locally-run server contains an absolute path, which is specific to your checkout.opencode.json.exampleis committed instead, andmake configrenders the real file from it.
Step 2: Scaffold a packaged project
uvx runs a console script from an installed package, so this project needs to be
a package — uv init --package, not a bare uv init.
Create the files
uv init --package --name mcp-chart-server --no-workspace
rm -f .python-version
uv add fastmcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "mcp-chart-server"
version = "0.1.0"
description = "An MCP server that serves an updating bar chart as an image resource"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.4",
]
[project.scripts]
mcp-chart-server = "mcp_chart_server:main"
[build-system]
requires = ["uv_build>=0.11.26,<0.12.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
[project.scripts]is the contract withuvx. The name on the left,mcp-chart-server, is the command;mcp_chart_server:mainis the callable it invokes. Everything else in this article assumes that pairing.--packagegives the src layout (src/mcp_chart_server/), which keeps the installed package and the working tree from shadowing each other during tests.fastmcpis the only runtime dependency. Rendering happens in the standard library, so nothing else follows the server into a client’s environment..python-versionis removed souvresolves an interpreter fromrequires-pythoninstead of pinning whatever was installed when you scaffolded.
Step 3: Write a PNG without an image library
A PNG is four pieces: an 8-byte signature, an IHDR chunk describing the raster, one or more IDAT chunks holding zlib-compressed scanlines, and an IEND terminator. Each chunk is length-prefixed and CRC-32 checked. For 8-bit RGB with no filtering, that is all of it.
Create the file
touch src/mcp_chart_server/png.py
Add the code: src/mcp_chart_server/png.py
"""A minimal, dependency-free PNG encoder.
Only what an 8-bit truecolour (RGB) image needs: the signature, an IHDR chunk, a
single zlib-compressed IDAT, and IEND. That is enough to produce a file every
image decoder accepts, without pulling Pillow into a server whose whole job is to
hand a client a few kilobytes of pixels.
Output is byte-for-byte deterministic for the same pixels, which is what lets the
tests assert on a digest instead of eyeballing a picture.
"""
from __future__ import annotations
import struct
import zlib
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def _chunk(tag: bytes, data: bytes) -> bytes:
"""Frame one PNG chunk: length, type, payload, CRC-32 of type+payload."""
body = tag + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body))
def encode_png(width: int, height: int, pixels: bytearray) -> bytes:
"""Encode a `width * height * 3` RGB buffer as an 8-bit truecolour PNG."""
expected = width * height * 3
if len(pixels) != expected:
raise ValueError(f"expected {expected} bytes of RGB, got {len(pixels)}")
stride = width * 3
# Every scanline is prefixed with its filter type; 0 means "no filtering".
raw = b"".join(
b"\x00" + bytes(pixels[y * stride : (y + 1) * stride]) for y in range(height)
)
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
return b"".join(
[
PNG_SIGNATURE,
_chunk(b"IHDR", ihdr),
_chunk(b"IDAT", zlib.compress(raw, 9)),
_chunk(b"IEND", b""),
]
)
Detailed breakdown
- The IHDR fields are width, height, bit depth 8, colour type 2 (truecolour
RGB), then compression, filter, and interlace methods, all 0. Colour type 2
means three bytes per pixel with no palette and no alpha, which is why the
buffer is
width * height * 3. - The filter byte on every scanline is what separates the raw buffer from a valid PNG. Filter 0 stores the row as-is. Real encoders pick a filter per row to compress better; at 240x120 with flat colour blocks the difference is tens of bytes and not worth the code.
- CRC-32 covers the chunk type and the payload, not just the payload.
Getting that wrong produces a file that opens in some decoders and not others,
which is a miserable bug to chase.
test_png.pyre-derives every CRC. zlib.compress(raw, 9)is deterministic for a given input, so the same readings always produce byte-identical output. Tests later assert on a SHA-256 because of this.- The size check is a guard, not ceremony. A short buffer produces a PNG that decodes to garbage rather than failing, so catching it here saves a confusing debugging session downstream.
Step 4: Draw a chart that says which revision it is
The image needs one property that is easy to overlook: it has to be self-identifying. A client asked to read the same URI twice has no way to tell two pictures apart unless the picture says so. A revision counter drawn into the corner turns “did it update?” into something you can answer by looking.
Create the file
touch src/mcp_chart_server/chart.py
Add the code: src/mcp_chart_server/chart.py
"""Draw the chart: a tiny RGB canvas, a 3x5 digit font, and the bar renderer.
The rendered image carries its own revision number in the top-left corner. That
matters more than it looks: a client asked to read the same URI twice has no way
to tell two images apart unless the picture itself says which one it is.
"""
from __future__ import annotations
from .png import encode_png
WIDTH = 240
HEIGHT = 120
WHITE = (255, 255, 255)
INK = (32, 32, 32)
BAR = (48, 110, 200)
LATEST = (230, 120, 20)
EMPTY = (200, 200, 200)
MAX_BARS = 12
MAX_VALUE = 100
PLOT_LEFT = 8
PLOT_RIGHT = WIDTH - 8
PLOT_BOTTOM = HEIGHT - 8
PLOT_TOP = 34
PLOT_HEIGHT = PLOT_BOTTOM - PLOT_TOP
# 3x5 bitmap glyphs, one string per row, "1" = ink. Only the characters the
# label needs: the digits plus the letters in "REV".
GLYPHS = {
"0": ("111", "101", "101", "101", "111"),
"1": ("010", "110", "010", "010", "111"),
"2": ("111", "001", "111", "100", "111"),
"3": ("111", "001", "111", "001", "111"),
"4": ("101", "101", "111", "001", "001"),
"5": ("111", "100", "111", "001", "111"),
"6": ("111", "100", "111", "101", "111"),
"7": ("111", "001", "001", "001", "001"),
"8": ("111", "101", "111", "101", "111"),
"9": ("111", "101", "111", "001", "111"),
"R": ("111", "101", "111", "110", "101"),
"E": ("111", "100", "111", "100", "111"),
"V": ("101", "101", "101", "101", "010"),
" ": ("000", "000", "000", "000", "000"),
}
class Canvas:
"""A fixed-size RGB pixel buffer with the three primitives this chart needs."""
def __init__(self, width: int, height: int, background: tuple[int, int, int]):
self.width = width
self.height = height
self.pixels = bytearray(bytes(background) * (width * height))
def rect(self, x0: int, y0: int, x1: int, y1: int, colour: tuple[int, int, int]) -> None:
"""Fill the half-open rectangle [x0, x1) x [y0, y1), clipped to the canvas."""
x0, y0 = max(0, x0), max(0, y0)
x1, y1 = min(self.width, x1), min(self.height, y1)
row = bytes(colour) * max(0, x1 - x0)
for y in range(y0, y1):
start = (y * self.width + x0) * 3
self.pixels[start : start + len(row)] = row
def text(self, x: int, y: int, label: str, colour: tuple[int, int, int], scale: int = 3) -> None:
"""Blit `label` using the 3x5 glyph table, each pixel scaled `scale` times."""
for char in label:
glyph = GLYPHS.get(char, GLYPHS[" "])
for row, bits in enumerate(glyph):
for col, bit in enumerate(bits):
if bit == "1":
px = x + col * scale
py = y + row * scale
self.rect(px, py, px + scale, py + scale, colour)
x += 4 * scale # 3 glyph columns plus a one-column gap
def render_chart(readings: list[int], revision: int) -> bytes:
"""Render the last `MAX_BARS` readings as a labelled bar chart, as PNG bytes."""
canvas = Canvas(WIDTH, HEIGHT, WHITE)
# Frame and baseline, so an empty chart still looks like a chart.
canvas.rect(0, 0, WIDTH, 2, INK)
canvas.rect(0, HEIGHT - 2, WIDTH, HEIGHT, INK)
canvas.rect(0, 0, 2, HEIGHT, INK)
canvas.rect(WIDTH - 2, 0, WIDTH, HEIGHT, INK)
canvas.rect(PLOT_LEFT, PLOT_BOTTOM, PLOT_RIGHT, PLOT_BOTTOM + 2, INK)
canvas.text(8, 8, f"REV {revision}", INK)
window = readings[-MAX_BARS:]
slot = (PLOT_RIGHT - PLOT_LEFT) // MAX_BARS
for index, value in enumerate(window):
clamped = max(0, min(MAX_VALUE, value))
bar_height = round(clamped / MAX_VALUE * PLOT_HEIGHT)
left = PLOT_LEFT + index * slot
colour = LATEST if index == len(window) - 1 else BAR
if bar_height == 0:
canvas.rect(left, PLOT_BOTTOM - 2, left + slot - 4, PLOT_BOTTOM, EMPTY)
else:
canvas.rect(left, PLOT_BOTTOM - bar_height, left + slot - 4, PLOT_BOTTOM, colour)
return encode_png(WIDTH, HEIGHT, canvas.pixels)
def render_svg(readings: list[int], revision: int) -> str:
"""The same chart as SVG, used to show what a client does with a MIME it will
not attach. Nothing here is drawn by the PNG path."""
window = readings[-MAX_BARS:]
slot = (PLOT_RIGHT - PLOT_LEFT) // MAX_BARS
bars = []
for index, value in enumerate(window):
clamped = max(0, min(MAX_VALUE, value))
bar_height = round(clamped / MAX_VALUE * PLOT_HEIGHT)
colour = "#e67814" if index == len(window) - 1 else "#306ec8"
bars.append(
f'<rect x="{PLOT_LEFT + index * slot}" y="{PLOT_BOTTOM - bar_height}" '
f'width="{slot - 4}" height="{bar_height}" fill="{colour}"/>'
)
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}">'
f'<rect width="{WIDTH}" height="{HEIGHT}" fill="#ffffff" stroke="#202020" stroke-width="4"/>'
f'<text x="8" y="26" font-family="monospace" font-size="18" fill="#202020">REV {revision}</text>'
+ "".join(bars)
+ "</svg>"
)
Detailed breakdown
Canvas.rectclips instead of raising. Every drawing call goes through it, so clipping in one place means the bar loop never has to think about the plot boundary.bytes(colour) * (x1 - x0)builds one row of the fill and splices it into the buffer, which is fast enough that the whole render is under a millisecond.- The glyph table exists because a bitmap font is smaller than a font
dependency. Fourteen characters at 3x5, scaled 3x, is enough to print
REV 12legibly. Uppercase is deliberate: at three pixels wide, lowercase letters are ambiguous. - The newest bar is a different colour. With a fixed 12-bar window, the orange bar is how you tell which end is “now” once the window is full and older readings start scrolling off.
- A zero reading draws a two-pixel grey stub. Otherwise a genuine zero and a missing reading look identical, and “the chart stopped updating” becomes indistinguishable from “the value is 0”.
- Values are clamped here, not rejected. Validation is the state layer’s job (Step 5); the renderer is total, so no input can make it raise mid-read.
render_svgdraws the same chart in markup. It exists to make one point concrete in Step 9: an SVG is text, so it travels astextrather thanblob, and no client attaches it as an image.
Step 5: Put the state in a file
The picture updates because the numbers behind it change. Where those numbers live decides who can change them.
Create the file
touch src/mcp_chart_server/state.py
Add the code: src/mcp_chart_server/state.py
"""File-backed chart state.
The state lives on disk rather than in the server process for one reason: it lets
something *other* than the model change the picture. A `make bump` from your
shell and a tool call from a model both land in the same JSON file, so a client
that re-reads the resource sees the new image either way.
The path defaults to `~/.mcp-chart-server/state.json` and is overridable with
`CHART_STATE`. It deliberately does not default to the current directory: a
server launched by an MCP client inherits that client's working directory, which
is rarely the one you were in when you configured it.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
DEFAULT_STATE_PATH = Path.home() / ".mcp-chart-server" / "state.json"
MAX_HISTORY = 64
MIN_VALUE = 0
MAX_VALUE = 100
def state_path() -> Path:
"""Resolve the state file, honouring `CHART_STATE` when it is set."""
override = os.environ.get("CHART_STATE")
return Path(override).expanduser() if override else DEFAULT_STATE_PATH
@dataclass
class ChartState:
revision: int = 0
readings: list[int] = field(default_factory=list)
@classmethod
def load(cls, path: Path | None = None) -> ChartState:
"""Read the state file, falling back to an empty chart if it is missing
or unreadable. A corrupt file is not worth crashing a server over."""
target = path or state_path()
try:
raw = json.loads(target.read_text())
except (FileNotFoundError, json.JSONDecodeError):
return cls()
readings = [int(v) for v in raw.get("readings", []) if isinstance(v, int)]
return cls(revision=int(raw.get("revision", 0)), readings=readings)
def save(self, path: Path | None = None) -> None:
"""Write the state atomically so a concurrent reader never sees a half file."""
target = path or state_path()
target.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(
{"revision": self.revision, "readings": self.readings}, indent=2
)
temp = target.with_suffix(".tmp")
temp.write_text(payload + "\n")
temp.replace(target)
def append(self, value: int) -> ChartState:
"""Add one reading and bump the revision. Values outside 0-100 are refused
rather than clamped, so a caller learns it sent something wrong."""
if not MIN_VALUE <= value <= MAX_VALUE:
raise ValueError(f"value must be between {MIN_VALUE} and {MAX_VALUE}, got {value}")
self.readings = (self.readings + [value])[-MAX_HISTORY:]
self.revision += 1
return self
def reset(self) -> ChartState:
self.revision = 0
self.readings = []
return self
Detailed breakdown
- The default path is absolute on purpose. An MCP client spawns the server as a child process and the child inherits the client’s working directory. A relative default would put the state file wherever opencode happened to be started, which is the kind of bug that presents as “my tool calls work but the resource never changes.”
CHART_STATEmakes the server testable. Every test in Step 8 points it at atmp_path, so the suite never touches the real chart and tests cannot interfere with each other.loadswallows a missing or corrupt file. A resource read is not the place to crash a long-lived server over an unparseable JSON file; an empty chart is a truthful answer.savewrites to a temp file and renames.Path.replaceis atomic on the same filesystem, so a reader either sees the old file or the new one. Without it, a read landing mid-write gets truncated JSON — whichloadwould then silently treat as an empty chart.appendrefuses out-of-range values instead of clamping. The renderer clamps because it must never fail; the state layer rejects because a caller that sent 500 should hear about it. That split is whattest_out_of_range_reading_is_refusedpins down.MAX_HISTORYbounds the file. Only twelve readings are ever drawn, but keeping a few more makes the window template in Step 6 meaningful without letting the file grow without limit.
Step 6: The resources
Create the file
touch src/mcp_chart_server/server.py
Add the code: src/mcp_chart_server/server.py
"""An MCP server whose headline resource is a PNG that changes.
Four resources over one piece of state:
- `chart://latest.png` - the chart as PNG bytes (a base64 blob on the wire)
- `chart://window/{count}` - the same chart over the last `count` readings
- `chart://latest.svg` - the same chart as SVG, to show what a client does with
a MIME type it will not attach
- `chart://state` - the numbers behind the picture, as JSON
Two tools mutate the state, which is what makes the image "updating": every
`add_reading` bumps the revision printed in the top-left corner, so the next read
of the same URI returns different bytes.
Two more tools, `get_chart` and `get_chart_state`, mirror the two headline
resources. Resources have to be handed to the model by the client; tools are
callable by the model itself. Clients that give the model no way to read a
resource — Claude Desktop 1.24012.9 among them — can still reach the same bytes
through the tool.
"""
from __future__ import annotations
import hashlib
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.resources import ResourceContent, ResourceResult
from fastmcp.utilities.types import Image
from .chart import MAX_BARS, render_chart, render_svg
from .state import ChartState, state_path
mcp = FastMCP(
"chart",
instructions=(
"Serves a bar chart of recent readings. Read chart://latest.png for the "
"picture and chart://state for the numbers. add_reading changes both. "
"If you cannot read resources, call get_chart and get_chart_state instead."
),
)
@mcp.resource(
"chart://latest.png",
name="Latest chart (PNG)",
description="Bar chart of the most recent readings, labelled with the current revision.",
mime_type="image/png",
tags={"chart", "image"},
)
def latest_png() -> bytes:
"""The image resource. Returning `bytes` is what makes this a blob."""
state = ChartState.load()
return render_chart(state.readings, state.revision)
@mcp.resource(
"chart://window/{count}",
name="Chart window",
description="The chart drawn over the last `count` readings (1-12).",
mime_type="image/png",
tags={"chart", "image"},
)
def chart_window(count: int) -> ResourceResult:
"""A template resource: the URI selects how much history to draw.
Note the return type. A template that returns bare `bytes` loses its declared
MIME type on the way out (FastMCP 3.4.4 sends `application/octet-stream`),
and a client that filters attachments by MIME will drop the image. Wrapping
the bytes in `ResourceContent` with an explicit `mime_type` is what keeps
`image/png` on the wire.
"""
if not 1 <= count <= MAX_BARS:
raise ToolError(f"count must be between 1 and {MAX_BARS}, got {count}")
state = ChartState.load()
png = render_chart(state.readings[-count:], state.revision)
return ResourceResult([ResourceContent(png, mime_type="image/png")])
@mcp.resource(
"chart://latest.svg",
name="Latest chart (SVG)",
description="The same chart as SVG. Most clients will not attach this one.",
mime_type="image/svg+xml",
tags={"chart", "image"},
)
def latest_svg() -> str:
"""Text, not bytes: an SVG is markup, so it travels as `text`, not `blob`."""
state = ChartState.load()
return render_svg(state.readings, state.revision)
@mcp.resource(
"chart://state",
name="Chart state",
description="Revision, readings, and the SHA-256 of the current PNG.",
mime_type="application/json",
tags={"chart"},
)
def chart_state() -> dict:
"""The numbers behind the picture, plus a digest a caller can compare across
reads to prove the image actually changed."""
state = ChartState.load()
png = render_chart(state.readings, state.revision)
return {
"revision": state.revision,
"readings": state.readings,
"png_bytes": len(png),
"png_sha256": hashlib.sha256(png).hexdigest(),
"state_file": str(state_path()),
}
@mcp.tool
def get_chart() -> Image:
"""Return the current chart as a PNG image.
The same bytes as `chart://latest.png`, reached by a route the model can take
on its own. `Image` is what makes this arrive as MCP `ImageContent` rather
than as base64 in a text block.
"""
state = ChartState.load()
return Image(data=render_chart(state.readings, state.revision), format="png")
@mcp.tool
def get_chart_state() -> dict:
"""Return the revision, the readings, and the digest of the current PNG."""
return chart_state()
@mcp.tool
def add_reading(value: int) -> dict:
"""Append a reading (0-100) to the chart and bump its revision."""
state = ChartState.load()
try:
state.append(value)
except ValueError as exc:
raise ToolError(str(exc)) from None
state.save()
return {"revision": state.revision, "readings": state.readings}
@mcp.tool
def reset_chart() -> dict:
"""Clear every reading and set the revision back to zero."""
state = ChartState.load().reset()
state.save()
return {"revision": state.revision, "readings": state.readings}
Detailed breakdown
- Returning
bytesis the whole trick for a binary resource. FastMCP sends abytesreturn as aBlobResourceContentswith a base64blobfield and notextfield. Do not base64-encode by hand; you will end up with the encoding applied twice and a client that reports a corrupt image. mime_typeis not decoration. It is the field clients filter on. Step 9 shows opencode refusing the exact same PNG bytes when they arrive labelledapplication/octet-stream, so this argument is the difference between an image the model sees and a line of text saying it was dropped.- The template must return
ResourceResult, notbytes. FastMCP 3.4.4’sResourceTemplate.convert_resultcallsResourceResult(raw_value)without passing the template’s MIME type, while the equivalent method on a static resource passesmime_type=self.mime_type. The result: a template returningbytesadvertisesimage/pnginresources/templates/listand then deliversapplication/octet-streamon read. Wrapping the bytes inResourceContent(png, mime_type="image/png")restores it. The mismatch between the advertised type and the delivered one is what makes this hard to spot. count: intis coerced for you. URI template parameters arrive as strings; FastMCP converts them based on the annotation, so a non-numeric segment fails before the function body runs.chart://statecarries the digest of the PNG. This is what turns “the image updated” from a claim into a check: a client can read the digest, read the image, and compare — which is what the smoke test and the Step 9 transcript both do.- Both mutating tools re-load state from disk before writing. The server keeps
nothing in memory, so a
make bumpbetween two tool calls is picked up rather than overwritten. get_chartreturnsImage, notbytes. A tool returning rawbytesgets serialized into a text block full of base64.fastmcp.utilities.types.Imagewraps the bytes into MCPImageContentwithmimeType: image/png, which is the shape a client renders.format="png"is what sets that MIME type — omit it and you get the class default, which happens to beimage/pnghere but is not something to rely on.get_chart_statecallschart_state()directly. FastMCP’s@mcp.resourcedecorator returns the undecorated function, so the resource body is still an ordinary callable. One implementation, two entry points, no chance of the tool and the resource drifting apart.
Why the same data is exposed twice
Resources and tools differ in who initiates the read. A resource is something the
client fetches and hands to the model — through a UI attachment control, an
@-mention, or a built-in tool the client provides. A tool is something the
model calls on its own.
That distinction stays invisible until a client makes the first path the only
path. In Claude Desktop 1.24012.9 the resource is reached by a human clicking
through the composer’s attachment menu — there is no prompt you can write that
makes the model fetch it, and the menu itself has moved between releases (Step 14
has the details and what happened when this article’s server was wired into it).
opencode, by contrast, gives the model a read_mcp_resource tool (Step 12), so
the same resource is model-reachable there. Same server, same resources/list
response, completely different reach.
So the four resources stay as they are, and two tools mirror the two that matter:
| Resource | Mirrored by | Same bytes? |
|---|---|---|
chart://latest.png | get_chart | Yes — one render_chart call, verified by test |
chart://state | get_chart_state | Yes — the tool calls the resource function |
Tools are listed in every MCP client and are model-callable everywhere, so this is the surface that works no matter what is on the other end. Resources remain the better shape for the data — they are addressable, cacheable, and cost nothing when unused — but a resource a model cannot reach is a resource that does not exist.
How wide that gap is, measured rather than assumed:
| Client | Model can reach a resource from a prompt? |
|---|---|
| opencode 1.18.5 | Yes — read_mcp_resource (Step 12) |
| Claude Code 2.1.220 | Yes — a prompt naming the URI produced resources/read 1 |
| Claude Desktop 1.24012.9 | No — resources/read 0 across every session (Step 14) |
The two Anthropic clients disagree with each other, which is the useful part: this is a per-client, per-version property, not something you can infer from who ships it. The tools are here so the server does not have to care which one it meets.
Step 7: The console-script entry point
Create the file
touch src/mcp_chart_server/__init__.py
Add the code: src/mcp_chart_server/__init__.py
"""Console-script entry point for `mcp-chart-server`.
This is the callable named in `[project.scripts]`, so it is what `uvx` runs. The
`--check` flag renders the chart and exits without serving, which turns "did the
package install correctly" into a question you can answer in under a second.
"""
from __future__ import annotations
import sys
from .chart import render_chart
from .state import ChartState, state_path
__all__ = ["main"]
def main(argv: list[str] | None = None) -> int:
args = sys.argv[1:] if argv is None else argv
if "--check" in args:
state = ChartState.load()
png = render_chart(state.readings, state.revision)
print(f"state file : {state_path()}")
print(f"revision : {state.revision}")
print(f"readings : {state.readings}")
print(f"png : {len(png)} bytes, magic {png[:8]!r}")
return 0
from .server import mcp
mcp.run()
return 0
if __name__ == "__main__":
raise SystemExit(main())
Detailed breakdown
--checkrenders without serving. A stdio server blocks forever by design, which makes “is this installed correctly?” awkward to answer.--checkprints the state path, the revision, and the PNG’s length and magic number, then exits 0. It is the first thing to run when a client reports a failed handshake.- The
serverimport is insidemain. Importing it at module scope would build the FastMCP instance during--checkfor no reason, and it keeps a broken server module from breaking the check that would diagnose it. mainreturns an int and the generated console script wraps it insys.exit, souvx --from . mcp-chart-server --checksets a usable exit code for a Makefile or a CI step.
Run it:
uvx --from "$(pwd)" mcp-chart-server --check
state file : /Users/you/.mcp-chart-server/state.json
revision : 0
readings : []
png : 373 bytes, magic b'\x89PNG\r\n\x1a\n'
An empty chart is 373 bytes. That number matters later: opencode caps an attached resource at 10 MiB, and a 240x120 chart is four orders of magnitude under it.
The uvx cache will serve you stale code
uvx --from <directory> builds the project once and caches the result. The cache
key is pyproject.toml’s modification time, not the contents of src/. Edit
the server, relaunch through uvx, and you are still running the old build.
Measured on uv 0.11.26, after appending a marker comment to a source file:
| Action | Picks up the edit? |
|---|---|
uvx --from <dir> … | no |
uvx --refresh --from <dir> … | no |
uvx --refresh-package mcp-chart-server --from <dir> … | no |
uv cache clean mcp-chart-server | no |
touch pyproject.toml | yes |
This costs real time when it bites, because everything looks correct: the server
connects, the tools are there, and the behaviour you just fixed is still broken.
The make rebuild target in Step 10 is a touch plus a --check. During
development, uv run mcp-chart-server bypasses the cache entirely and always runs
the working tree.
Step 8: Test the bytes, not the picture
Create the files
mkdir -p tests
touch tests/__init__.py
touch pytest.ini
touch tests/test_png.py
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 per-test marker.
Add the code: tests/test_png.py
"""Tests for the PNG encoder and the chart renderer.
These are pure functions over bytes, so they assert on structure and on exact
digests rather than on how the picture looks.
"""
import hashlib
import struct
import zlib
import pytest
from mcp_chart_server.chart import HEIGHT, WIDTH, Canvas, render_chart, render_svg
from mcp_chart_server.png import PNG_SIGNATURE, encode_png
def chunks(png: bytes):
"""Walk a PNG's chunk list, yielding (tag, payload) and checking each CRC."""
assert png[:8] == PNG_SIGNATURE
offset = 8
while offset < len(png):
(length,) = struct.unpack(">I", png[offset : offset + 4])
tag = png[offset + 4 : offset + 8]
payload = png[offset + 8 : offset + 8 + length]
(crc,) = struct.unpack(">I", png[offset + 8 + length : offset + 12 + length])
assert crc == zlib.crc32(tag + payload), f"bad CRC on {tag!r}"
yield tag, payload
offset += 12 + length
def test_encode_png_structure():
png = encode_png(2, 1, bytearray(b"\xff\x00\x00\x00\xff\x00"))
tags = [tag for tag, _ in chunks(png)]
assert tags == [b"IHDR", b"IDAT", b"IEND"]
def test_ihdr_declares_8bit_truecolour():
png = encode_png(4, 3, bytearray(b"\x10\x20\x30" * 12))
header = dict(chunks(png))[b"IHDR"]
width, height, depth, colour, compression, filt, interlace = struct.unpack(
">IIBBBBB", header
)
assert (width, height) == (4, 3)
assert (depth, colour) == (8, 2) # 8 bits per sample, truecolour RGB
assert (compression, filt, interlace) == (0, 0, 0)
def test_idat_round_trips_to_filtered_scanlines():
pixels = bytearray(b"\x01\x02\x03\x04\x05\x06" * 2) # 2x2 RGB
png = encode_png(2, 2, pixels)
raw = zlib.decompress(dict(chunks(png))[b"IDAT"])
# Each scanline is 1 filter byte (0 = None) plus width*3 colour bytes.
assert len(raw) == 2 * (1 + 2 * 3)
assert raw[0] == 0 and raw[7] == 0
assert raw[1:7] + raw[8:14] == bytes(pixels)
def test_encode_png_rejects_a_wrong_sized_buffer():
with pytest.raises(ValueError, match="expected 12 bytes"):
encode_png(2, 2, bytearray(b"\x00" * 11))
def test_canvas_rect_clips_to_the_canvas():
canvas = Canvas(3, 1, (0, 0, 0))
canvas.rect(-5, -5, 99, 99, (255, 255, 255))
assert canvas.pixels == bytearray(b"\xff" * 9)
def test_render_chart_is_deterministic():
first = render_chart([10, 20, 30], 3)
second = render_chart([10, 20, 30], 3)
assert first == second
assert first[:8] == PNG_SIGNATURE
def test_revision_changes_the_bytes():
"""The revision is drawn into the image, so two revisions of the same
readings are different pictures. This is what makes an update visible."""
assert render_chart([10, 20, 30], 3) != render_chart([10, 20, 30], 4)
def test_readings_change_the_bytes():
assert render_chart([10, 20, 30], 3) != render_chart([10, 20, 31], 3)
def test_empty_chart_still_renders():
png = render_chart([], 0)
assert dict(chunks(png)) # parses, CRCs check out
assert len(png) < 1024 # a few hundred bytes, nowhere near any client cap
def test_values_are_clamped_not_rejected_by_the_renderer():
"""Validation belongs to the state layer; the renderer never raises."""
assert render_chart([-40, 500], 1) == render_chart([0, 100], 1)
def test_only_the_last_twelve_readings_are_drawn():
long_run = list(range(0, 100, 5)) # 20 readings
assert render_chart(long_run, 20) == render_chart(long_run[-12:], 20)
def test_known_digest_of_the_empty_chart():
"""A canary: if the renderer or the encoder changes, this digest moves and
the tests that compare 'before' and 'after' images need a second look."""
assert (
hashlib.sha256(render_chart([], 0)).hexdigest()
== "f1dfbaf41d2e975e99e4f558864a084a18281d4fcf3e47a30e26cff6ec051b95"
)
def test_render_svg_is_markup_not_bytes():
svg = render_svg([10, 20], 2)
assert isinstance(svg, str)
assert svg.startswith(f'<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}">')
assert "REV 2" in svg
assert svg.count("<rect") == 3 # background plus one per reading
Detailed breakdown
chunksis a parser, not a helper. It re-derives every CRC from the chunk type and payload, so a bug in_chunk’s framing fails here rather than in an image viewer.test_idat_round_trips_to_filtered_scanlinesdecompresses the IDAT and checks that the filter bytes are present and the colour bytes survive intact. That is the one part of the encoder a structural check would otherwise miss.- The two “changes the bytes” tests are the article’s premise in test form: bumping the revision alone changes the picture, and so does changing a reading.
test_known_digest_of_the_empty_chartis a canary. It has no meaning on its own; it exists so that a change to the renderer trips one obvious test instead of quietly invalidating every before/after comparison elsewhere.
Add the code: tests/test_server.py
"""Tests for the MCP surface: what a client actually receives.
Every test drives the server through an in-memory FastMCP `Client`, so the
assertions are about wire content — blob vs text, MIME type, and whether the
same URI returns different bytes after the state changes.
`CHART_STATE` is redirected to a temp file per test, so the suite never touches
the real chart.
"""
import base64
import hashlib
import json
import pytest
from fastmcp import Client
from mcp.shared.exceptions import McpError
from mcp_chart_server.chart import render_chart
from mcp_chart_server.server import mcp
# opencode 1.18.5 attaches a binary MCP resource only if its MIME type is in
# this set, and only below 10 MiB. Other clients apply comparable rules.
OPENCODE_ATTACHABLE = {
"application/pdf",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
}
OPENCODE_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024
@pytest.fixture(autouse=True)
def scratch_state(tmp_path, monkeypatch):
monkeypatch.setenv("CHART_STATE", str(tmp_path / "state.json"))
async def test_resources_are_listed_with_their_metadata():
async with Client(mcp) as client:
resources = {str(r.uri): r for r in await client.list_resources()}
assert set(resources) == {
"chart://latest.png",
"chart://latest.svg",
"chart://state",
}
png = resources["chart://latest.png"]
assert png.mimeType == "image/png"
assert png.name == "Latest chart (PNG)"
async def test_template_is_listed_separately():
async with Client(mcp) as client:
templates = {t.uriTemplate: t for t in await client.list_resource_templates()}
assert set(templates) == {"chart://window/{count}"}
assert templates["chart://window/{count}"].mimeType == "image/png"
async def test_png_arrives_as_a_blob_not_text():
async with Client(mcp) as client:
content = (await client.read_resource("chart://latest.png"))[0]
assert content.mimeType == "image/png"
assert getattr(content, "text", None) is None
assert base64.b64decode(content.blob).startswith(b"\x89PNG\r\n\x1a\n")
async def test_png_would_be_attached_by_opencode():
"""The two client-side rules that decide whether the image survives."""
async with Client(mcp) as client:
content = (await client.read_resource("chart://latest.png"))[0]
raw = base64.b64decode(content.blob)
assert content.mimeType in OPENCODE_ATTACHABLE
assert len(raw) < OPENCODE_MAX_ATTACHMENT_BYTES
async def test_svg_arrives_as_text_and_would_be_omitted():
"""SVG is markup, so it travels as text. Were it sent as a blob, opencode
would refuse it: image/svg+xml is not in the attachable set."""
async with Client(mcp) as client:
content = (await client.read_resource("chart://latest.svg"))[0]
assert content.mimeType == "image/svg+xml"
assert content.text.startswith("<svg ")
assert getattr(content, "blob", None) is None
assert content.mimeType not in OPENCODE_ATTACHABLE
async def test_adding_a_reading_changes_the_image():
"""The claim the whole article rests on: same URI, different bytes."""
async with Client(mcp) as client:
before = base64.b64decode(
(await client.read_resource("chart://latest.png"))[0].blob
)
await client.call_tool("add_reading", {"value": 42})
after = base64.b64decode(
(await client.read_resource("chart://latest.png"))[0].blob
)
assert before != after
async def test_state_resource_digest_matches_the_png():
async with Client(mcp) as client:
for value in (30, 55, 20):
await client.call_tool("add_reading", {"value": value})
state = json.loads((await client.read_resource("chart://state"))[0].text)
png = base64.b64decode(
(await client.read_resource("chart://latest.png"))[0].blob
)
assert state["revision"] == 3
assert state["readings"] == [30, 55, 20]
assert state["png_bytes"] == len(png)
assert state["png_sha256"] == hashlib.sha256(png).hexdigest()
async def test_revision_survives_a_reconnect():
"""State lives in a file, not in the process, so a client that reconnects
(or a second client entirely) sees the same chart."""
async with Client(mcp) as client:
await client.call_tool("add_reading", {"value": 70})
async with Client(mcp) as client:
state = json.loads((await client.read_resource("chart://state"))[0].text)
assert state["revision"] == 1
assert state["readings"] == [70]
async def test_reset_clears_the_chart():
async with Client(mcp) as client:
await client.call_tool("add_reading", {"value": 70})
result = await client.call_tool("reset_chart", {})
assert result.data == {"revision": 0, "readings": []}
png = base64.b64decode(
(await client.read_resource("chart://latest.png"))[0].blob
)
assert png == render_chart([], 0)
async def test_out_of_range_reading_is_refused():
async with Client(mcp) as client:
with pytest.raises(Exception, match="between 0 and 100"):
await client.call_tool("add_reading", {"value": 101})
state = json.loads((await client.read_resource("chart://state"))[0].text)
assert state["revision"] == 0 # the failed call changed nothing
async def test_template_keeps_its_mime_type():
"""A template returning bare `bytes` would come back as
application/octet-stream in FastMCP 3.4.4, which a client would refuse to
attach. Wrapping in ResourceContent is what keeps this assertion true."""
async with Client(mcp) as client:
for value in (10, 20, 30, 40):
await client.call_tool("add_reading", {"value": value})
content = (await client.read_resource("chart://window/2"))[0]
assert content.mimeType == "image/png"
assert content.mimeType in OPENCODE_ATTACHABLE
assert base64.b64decode(content.blob) == render_chart([30, 40], 4)
async def test_template_rejects_an_out_of_range_window():
async with Client(mcp) as client:
with pytest.raises(McpError, match="between 1 and 12"):
await client.read_resource("chart://window/99")
async def test_the_tool_fallback_is_listed():
"""The tools a model can call without the client handing it a resource."""
async with Client(mcp) as client:
names = {tool.name for tool in await client.list_tools()}
assert names == {"add_reading", "get_chart", "get_chart_state", "reset_chart"}
async def test_get_chart_returns_image_content():
"""A tool returning `Image` produces MCP ImageContent, not base64 in text."""
async with Client(mcp) as client:
result = await client.call_tool("get_chart", {})
content = result.content[0]
assert content.type == "image"
assert content.mimeType == "image/png"
assert base64.b64decode(content.data).startswith(b"\x89PNG\r\n\x1a\n")
async def test_get_chart_matches_the_resource_byte_for_byte():
"""The fallback is the same picture, not a second rendering path."""
async with Client(mcp) as client:
for value in (30, 55, 20, 80, 45):
await client.call_tool("add_reading", {"value": value})
via_tool = base64.b64decode((await client.call_tool("get_chart", {})).content[0].data)
via_resource = base64.b64decode(
(await client.read_resource("chart://latest.png"))[0].blob
)
assert via_tool == via_resource == render_chart([30, 55, 20, 80, 45], 5)
async def test_get_chart_state_matches_the_state_resource():
async with Client(mcp) as client:
await client.call_tool("add_reading", {"value": 42})
via_tool = (await client.call_tool("get_chart_state", {})).data
via_resource = json.loads((await client.read_resource("chart://state"))[0].text)
assert via_tool == via_resource
assert via_tool["revision"] == 1
async def test_get_chart_tracks_an_update():
"""Same call, different bytes: the fallback is as live as the resource."""
async with Client(mcp) as client:
before = (await client.call_tool("get_chart", {})).content[0].data
await client.call_tool("add_reading", {"value": 42})
after = (await client.call_tool("get_chart", {})).content[0].data
assert before != after
Detailed breakdown
OPENCODE_ATTACHABLEencodes a client’s rules as a test constant. Those five MIME types and the 10 MiB cap are what opencode 1.18.5 checks before it will attach a binary resource. Writing them down here means a future change to the server’s MIME type fails the suite instead of failing silently in a client.test_png_arrives_as_a_blob_not_textasserts the absence oftextas well as the presence ofblob, because that pair is how a client decides which branch to take.test_adding_a_reading_changes_the_imageis the article’s core claim, reduced to four lines: read, mutate, read, compare.test_revision_survives_a_reconnectis the payoff of file-backed state. A client that reconnects — which opencode does on everyopencode run— sees the same chart.test_template_keeps_its_mime_typeis a regression test for the FastMCP behaviour in Step 6. Drop theResourceContentwrapper and it fails.- The autouse
scratch_statefixture redirectsCHART_STATEper test, so tests are order-independent and your real chart is never touched. test_get_chart_matches_the_resource_byte_for_byteis what keeps the fallback honest. Two entry points to one picture is only safe while they stay identical; this test fails the moment they do not.test_get_chart_returns_image_contentcheckscontent.type == "image", which is the difference between a rendered picture and a wall of base64 in a text block. Returningbytesfrom the tool instead ofImageproduces the latter and fails here.
Run them:
uv run pytest -q
.............................. [100%]
30 passed in 0.39s
Step 9: Prove it over stdio, with no model in the loop
A model in the loop makes a failure ambiguous — the server, the launch command, or the model choosing not to call anything. A direct stdio client removes two of those, and it runs the exact command the MCP client will run.
Create the file
mkdir -p scripts
touch scripts/smoke_test.py
Add the code: scripts/smoke_test.py
# /// script
# requires-python = ">=3.12"
# dependencies = ["mcp>=1.9.0"]
# ///
"""Drive mcp-chart-server over stdio with the exact command an MCP client uses.
No model is involved. This proves three things in order:
1. `uvx --from <project> mcp-chart-server` launches and completes the handshake.
2. `chart://latest.png` comes back as a base64 blob with `image/png`, and the
bytes really are a PNG.
3. Calling `add_reading` changes the image: same URI, different digest.
Exported PNGs land in `out/` so you can open them and see the revision counter
move.
Usage:
uv run --script scripts/smoke_test.py [/abs/path/to/project]
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import os
import sys
import tempfile
from pathlib import Path
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
PROJECT = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parent.parent
OUT = PROJECT / "out"
# Must match the "command" array in opencode.json.
COMMAND = "uvx"
ARGS = ["--from", str(PROJECT), "mcp-chart-server"]
def digest(blob: str) -> tuple[bytes, str]:
raw = base64.b64decode(blob)
return raw, hashlib.sha256(raw).hexdigest()
async def main() -> int:
OUT.mkdir(exist_ok=True)
# A scratch state file keeps the run reproducible and leaves the real one alone.
with tempfile.TemporaryDirectory() as tmp:
env = {**os.environ, "CHART_STATE": str(Path(tmp) / "state.json")}
params = StdioServerParameters(command=COMMAND, args=ARGS, env=env)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
init = await session.initialize()
print(f"server : {init.serverInfo.name} {init.serverInfo.version}")
print(f"resources : {init.capabilities.resources is not None}")
listed = sorted(str(r.uri) for r in (await session.list_resources()).resources)
print(f"static : {listed}")
assert listed == [
"chart://latest.png",
"chart://latest.svg",
"chart://state",
], listed
templates = [
t.uriTemplate
for t in (await session.list_resource_templates()).resourceTemplates
]
print(f"templates : {templates}")
assert templates == ["chart://window/{count}"], templates
# Read 1: the empty chart.
content = (await session.read_resource("chart://latest.png")).contents[0]
assert content.mimeType == "image/png", content.mimeType
assert getattr(content, "text", None) is None, "a PNG must not arrive as text"
first, first_sha = digest(content.blob)
assert first.startswith(b"\x89PNG\r\n\x1a\n"), first[:8]
(OUT / "chart-before.png").write_bytes(first)
print(f"read 1 : {len(first)} bytes sha256={first_sha[:16]}...")
# Change the state through a tool, the way a model would.
for value in (30, 55, 20, 80, 45):
result = await session.call_tool("add_reading", {"value": value})
state = json.loads(result.content[0].text)
print(f"add_reading: revision={state['revision']} readings={state['readings']}")
assert state["revision"] == 5, state
# Read 2: same URI, new picture.
content = (await session.read_resource("chart://latest.png")).contents[0]
second, second_sha = digest(content.blob)
(OUT / "chart-after.png").write_bytes(second)
print(f"read 2 : {len(second)} bytes sha256={second_sha[:16]}...")
assert second_sha != first_sha, "the image did not change"
# The JSON resource agrees with the bytes it describes.
content = (await session.read_resource("chart://state")).contents[0]
described = json.loads(content.text)
assert described["png_sha256"] == second_sha, described
assert described["revision"] == 5, described
print(f"state : revision={described['revision']} png={described['png_bytes']}B")
# The template narrows the window without touching the state.
content = (await session.read_resource("chart://window/2")).contents[0]
window, _ = digest(content.blob)
(OUT / "chart-window-2.png").write_bytes(window)
assert content.mimeType == "image/png"
print(f"window/2 : {len(window)} bytes")
# SVG travels as text, not as a blob. Clients treat the two differently.
content = (await session.read_resource("chart://latest.svg")).contents[0]
assert content.mimeType == "image/svg+xml", content.mimeType
assert content.text.startswith("<svg "), content.text[:40]
assert getattr(content, "blob", None) is None
print(f"svg : {len(content.text)} chars of text, no blob")
# The tool fallback, for clients that give the model no way to
# read a resource. Same bytes, reached by a model-callable route.
image = (await session.call_tool("get_chart", {})).content[0]
assert image.type == "image", image.type
assert image.mimeType == "image/png", image.mimeType
tool_png, tool_sha = digest(image.data)
assert tool_sha == second_sha, (tool_sha, second_sha)
print(f"get_chart : {len(tool_png)} bytes sha256={tool_sha[:16]}... (same)")
print(f"\nPASS - the image changed after add_reading. PNGs written to {OUT}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
Detailed breakdown
- The PEP 723 header lets
uv run --scriptbuild a throwaway environment containing the officialmcpSDK. Nothing is installed into the project. COMMANDandARGSmirror the client config exactly. A pass here predicts that opencode’s launch will succeed, for the same reason it succeeded here. Keep them in sync when the launch command changes.CHART_STATEpoints at a temp directory, so the run is reproducible and your real chart is untouched. The revision always ends at 5.assert second_sha != first_shais the load-bearing assertion. Everything else confirms shape; this one confirms the resource is actually live.- The state resource is cross-checked against the image. Its
png_sha256has to equal the digest of the bytes just read, which catches a server that renders the picture and the metadata from different snapshots of the state. - The SVG read asserts
textand the absence ofblob. Same drawing, same server, different wire representation — which is what determines whether a client can render it. - The
get_chartcall compares its digest againstread 2. The tool and the resource have to be the same picture over the real transport, not just in the in-memory tests, because the tool is what a client without resource access will be using.
Run it:
uv run --script scripts/smoke_test.py
server : chart 3.4.4
resources : True
static : ['chart://latest.png', 'chart://latest.svg', 'chart://state']
templates : ['chart://window/{count}']
read 1 : 373 bytes sha256=f1dfbaf41d2e975e...
add_reading: revision=5 readings=[30, 55, 20, 80, 45]
read 2 : 465 bytes sha256=1786bcf09b2b62e0...
state : revision=5 png=465B
window/2 : 420 bytes
svg : 531 chars of text, no blob
get_chart : 465 bytes sha256=1786bcf09b2b62e0... (same)
PASS - the image changed after add_reading. PNGs written to .../out
open out/chart-before.png out/chart-after.png shows the two images: an empty
frame labelled REV 0, and five bars labelled REV 5 with the last one orange.
The FastMCP banner appears on stderr alongside this output. That is the server announcing itself, not an error.
Look at it in the MCP Inspector — the only place the picture is drawn
The smoke test asserts; the MCP Inspector lets you click. It is also, of the three clients in this article, the only one that renders the image rather than describing it or naming it, so it is worth setting up before opencode and long before Claude Desktop. Treat it as the display surface for this server and the chat clients as delivery paths to a model.
It speaks the same stdio protocol, so it takes the same launch command — run it
from the project directory and $(pwd) supplies the absolute path every client
config needs:
npx @modelcontextprotocol/inspector uvx --from "$(pwd)" mcp-chart-server
Starting MCP inspector...
⚙️ Proxy server listening on localhost:6277
🔑 Session token: 3f9c…
Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth
🚀 MCP Inspector is up and running at:
http://localhost:6274/?MCP_PROXY_PORT=6277&MCP_PROXY_AUTH_TOKEN=3f9c…
Open that URL — the token is in it, so a bare localhost:6274 will not
authenticate — and click Connect. Under Resources → List Resources,
chart://latest.png reads back as a rendered picture rather than as base64, which
makes it the fastest way to answer “is the chart actually what I think it is?”.
Run make bump VALUE=90 in a terminal and click the resource again to watch the
revision move. Resource Templates lists chart://window/{count} with a field
for count, and Tools runs get_chart and shows the image the same way.
make inspect wraps that command. Two things to know:
- The ports are 6274 (UI) and 6277 (proxy), and a second Inspector fails with
❌ Proxy Server PORT IS IN USE at port 6277 ❌.CLIENT_PORTandSERVER_PORTmove them:CLIENT_PORT=6284 SERVER_PORT=6287 make inspect. uvx --fromcaches its build (Step 7), so an Inspector session started before an edit keeps serving the old code.make rebuildfirst.
There is a --cli mode as well, which prints JSON and exits — useful in a script,
and the fastest way to see exactly what a client sees. make resources is this
one:
npx @modelcontextprotocol/inspector --cli \
uvx --from "$(pwd)" mcp-chart-server --method resources/list
{
"resources": [
{
"name": "Latest chart (PNG)",
"uri": "chart://latest.png",
"description": "Bar chart of the most recent readings, labelled with the current revision.",
"mimeType": "image/png",
"_meta": { "fastmcp": { "tags": ["chart", "image"] } }
},
…
]
}
That is the name=, description=, and mime_type= from Step 6 as they reach a
client — worth a look once, because those three strings are all a client has to
decide what to show a person and whether the bytes are attachable.
Other methods take the same shape. resources/read returns the base64 blob,
tools/call the image content, and both can be piped into a digest check:
npx @modelcontextprotocol/inspector --cli uvx --from "$(pwd)" mcp-chart-server \
--method resources/read --uri chart://latest.png \
| python3 -c 'import sys,json,base64,hashlib; b=json.load(sys.stdin)["contents"][0]["blob"]; r=base64.b64decode(b); print(len(r),"bytes",hashlib.sha256(r).hexdigest()[:16])'
465 bytes 1786bcf09b2b62e0
Swapping --method tools/call --tool-name get_chart (and ["content"][0]["data"]
for the blob field) prints the same 465 bytes and the same digest, which is the
Step 6 tool fallback checked against the resource from outside the project’s own
test suite.
Step 10: Change the chart from your shell
The smoke test changes the chart through a tool call. Because the state is a file, anything can change it — and a change from outside is the more convincing demo, since it rules out the client having cached a tool result.
Create the file
touch scripts/chartctl.py
Add the code: scripts/chartctl.py
"""Change the chart from your shell, without an MCP client in the loop.
Because the state lives in a file, a `bump` here and an `add_reading` tool call
from a model are the same operation. That is what makes the demo convincing: ask
a client to read the image, change it from another terminal, ask again.
uv run python scripts/chartctl.py bump 42
uv run python scripts/chartctl.py show
uv run python scripts/chartctl.py export out/chart.png
uv run python scripts/chartctl.py reset
"""
from __future__ import annotations
import sys
from pathlib import Path
from mcp_chart_server.chart import render_chart
from mcp_chart_server.state import ChartState, state_path
USAGE = "usage: chartctl.py {bump <0-100> | reset | show | export <path>}"
def main(argv: list[str]) -> int:
if not argv:
print(USAGE, file=sys.stderr)
return 2
command, args = argv[0], argv[1:]
if command == "bump":
if len(args) != 1 or not args[0].lstrip("-").isdigit():
print(USAGE, file=sys.stderr)
return 2
state = ChartState.load()
try:
state.append(int(args[0]))
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
state.save()
elif command == "reset":
state = ChartState.load().reset()
state.save()
elif command == "show":
state = ChartState.load()
elif command == "export":
if len(args) != 1:
print(USAGE, file=sys.stderr)
return 2
state = ChartState.load()
target = Path(args[0])
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(render_chart(state.readings, state.revision))
print(f"wrote {target}")
else:
print(USAGE, file=sys.stderr)
return 2
print(f"state file : {state_path()}")
print(f"revision : {state.revision}")
print(f"readings : {state.readings}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
Detailed breakdown
- It imports the package, not the server. No FastMCP instance is built and no MCP machinery runs, which keeps the “outside change” genuinely outside.
exportwrites the current chart to a file so you can open it in Preview next to what a client reports.- Every command prints the state path. When a client and your shell disagree
about the revision, the first question is whether they are reading the same
file — usually they are not, because one of them has
CHART_STATEset.
Drive it:
uv run python scripts/chartctl.py reset
uv run python scripts/chartctl.py bump 30
uv run python scripts/chartctl.py bump 55
state file : /Users/you/.mcp-chart-server/state.json
revision : 2
readings : [30, 55]
Step 11: Wire it into opencode
opencode launches a local MCP server as a child process and talks stdio to it,
which is exactly what uvx --from <dir> mcp-chart-server provides. Register it
globally with one command:
opencode mcp add chart -- uvx --from "$(pwd)" mcp-chart-server
◆ MCP server "chart" added to /Users/you/.config/opencode/opencode.json
Everything after -- becomes the launch command array. The absolute path matters:
opencode spawns the server from whatever directory the session started in, so a
relative --from . would resolve somewhere unintended.
Confirm the handshake:
opencode mcp list
● ✓ chart connected
│ uvx --from /Users/you/projects/updating-image-mcp-resource-macos mcp-chart-server
Scoping it to one project
opencode mcp add writes to ~/.config/opencode/opencode.json regardless of
where you run it, so every session on the machine pays for the chart tools. For a
server that only matters to one codebase, put it in a project-level
opencode.json instead. Because the launch command holds an absolute path, commit
a template and render the real file.
Create the file
touch opencode.json.example
Add the code: opencode.json.example
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"chart": {
"type": "local",
"command": [
"uvx",
"--from",
"/absolute/path/to/updating-image-mcp-resource-macos",
"mcp-chart-server"
],
"enabled": true
}
}
}
Detailed breakdown
type: "local"means opencode spawns a process and speaks stdio to it. The alternative,"remote", takes aurlinstead of acommand.commandis an array, not a string. No shell is involved, so quoting andPATHexpansion do not apply, anduvxmust be resolvable in opencode’s environment.- Project and global configs merge. Moving a server from global to project scope means deleting the global entry too, or you have narrowed nothing.
- The placeholder path is substituted by
make configin Step 13, which writes a gitignoredopencode.jsonfor your checkout.
Step 12: Read the image from a model
opencode 1.18.5 gives the model three built-in tools for resources, separate from whatever tools your server exposes:
| Tool | What it does |
|---|---|
list_mcp_resources | Lists resources across connected servers, or one named server |
list_mcp_resource_templates | The same for templates |
read_mcp_resource | Reads one URI from one server |
read_mcp_resource runs under opencode’s read permission, matched against the
pattern mcp:<server>:<uri>. Its log line during a successful read:
message=evaluated permission=read pattern=mcp:chart:chart://latest.png
action.permission=read action.pattern=* action.action=allow
Set the chart to a known state, then ask:
uv run python scripts/chartctl.py reset
for v in 30 55 20 80 45; do uv run python scripts/chartctl.py bump $v; done
opencode run -m ollama/qwen3.5:latest \
"Read the MCP resource chart://latest.png from the chart server, \
then read chart://state, and tell me the revision."
> build · qwen3.5:latest
⚙ read_mcp_resource MCP resource: chart://latest.png
⚙ read_mcp_resource MCP resource: chart://state
From `chart://state`, the current **revision is 5** with these readings: `[30, 55, 20, 80, 45]`.
Two things happened that the transcript does not spell out.
The PNG became an image attachment. opencode’s stored tool result for the first call is:
output: [Binary MCP resource attached: chart://latest.png (image/png)]
metadata: {"server": "chart", "uri": "chart://latest.png",
"contents": 1, "attachments": 1, "truncated": false}
alongside an attachment of {"type": "file", "mime": "image/png", "url": "data:image/png;base64,…", "filename": "chart://latest.png"}. Decoding that data
URL gives 465 bytes with SHA-256 1786bcf09b2b62e0… — byte-identical to what
render_chart([30, 55, 20, 80, 45], 5) produces locally and to the smoke test’s
read 2. The image the model was handed is the image the server drew.
The text resource came through in a fixed frame. chart://state arrives as:
Resource: chart://state
MIME: application/json
{"revision": 5, "readings": [30, 55, 20, 80, 45], ...}
opencode prepends the URI and MIME type to every text resource, which is worth knowing if you were counting on the model seeing only your JSON.
Now change the chart from another terminal and ask again:
uv run python scripts/chartctl.py bump 95
opencode run -m ollama/qwen3.5:latest \
"Read chart://latest.png and chart://state from the chart server \
and report the revision."
The attachment is now 496 bytes with SHA-256 0e053508a432fcee…, and the model
reports revision 6. Same URI, no restart, different picture.
What opencode does not do is show it to you
Read the transcript above again for what is missing. The model got a picture; you
got two lines of ASCII. opencode’s own record of the read is the string
[Binary MCP resource attached: chart://latest.png (image/png)] — a description
of an image, in a terminal that has no image in it. Every digest in this section
was recovered by decoding a stored data URL after the fact, not by looking at
anything on screen.
That is not a criticism of opencode, which is a terminal program doing the
sensible thing with 465 bytes of PNG. It is the point of the table at the top of
this article: loaded and displayed are two different capabilities, and this
client has the first and not the second. Claude Desktop lands in the same place by
a different route (Step 14). If you want to see the chart while working in
opencode, keep a second terminal on make open — the exported PNG in Preview
updates when you re-export, and it costs nothing.
Whether the model can see it is a separate question
opencode delivers the attachment; the model still has to be able to read images, and the provider has to forward them. Running against a local Ollama model through opencode’s OpenAI-compatible provider, the attachment arrives and the model cannot use it:
⚙ read_mcp_resource MCP resource: chart://latest.png
The system is unable to process or "read" the attached image resource
`chart://latest.png` because it does not support interpreting images.
That is not the model lacking vision. Posting the same 465 bytes directly to
Ollama’s /v1/chat/completions as an image_url content part, with the same
model, gets a description back:
1. The number printed at the top left: The number visible and emphasized
in the header is 5.
2. How many bars are there: There are 3 bars shown.
It read REV 5 off the picture correctly and miscounted the bars, which is a
small vision model being a small vision model. The point is that the image is
legible and the failure above is in the delivery path, not the resource. So:
three separate things can break a picture on its way to a model, and they fail
differently.
| Layer | Failure looks like |
|---|---|
| Server MIME type | [Binary MCP resource omitted: … is not a supported attachment type] |
| Client attachment rules | Same message, or one about the 10 MiB cap |
| Model or provider | The model says it cannot interpret images |
| Client rendering | The model can describe the image; the transcript shows none |
The first three rows are things you can fix. The last row is not a failure at all from the client’s point of view, and it is where both chat clients in this article sit — opencode above, Claude Desktop in Step 14. A client can hand the model a correct image and draw nothing for the person reading, and the only way to tell that apart from a genuine drop is to ask the model something the pixels answer and your text does not. When you need to confirm the picture itself is fine, use the Inspector from Step 9 — it renders what it reads, with no model anywhere in the path.
Step 13: Wrap it in a Makefile
Create the file
touch Makefile
Add the code: Makefile
# updating-image-mcp-resource-macos/Makefile
.DEFAULT_GOAL := help
SERVER := chart
VALUE ?= 42
.PHONY: help
help: ## Show this help screen
@echo "An updating image as an MCP resource - available targets:"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "} {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
@echo ""
@echo "Launch command: uvx --from $(CURDIR) mcp-chart-server"
.PHONY: install
install: ## Sync runtime and dev dependencies
uv sync
.PHONY: test
test: ## Run the pytest suite
uv run pytest -q
.PHONY: check
check: ## Render the chart through the packaged entry point (no server)
uvx --from $(CURDIR) mcp-chart-server --check
.PHONY: smoke
smoke: ## Drive the server over stdio and prove the image changes
uv run --script scripts/smoke_test.py $(CURDIR)
.PHONY: inspect
inspect: ## Open the MCP Inspector against this project (needs Node)
npx @modelcontextprotocol/inspector uvx --from $(CURDIR) mcp-chart-server
.PHONY: resources
resources: ## List the server's resources through the Inspector CLI
npx @modelcontextprotocol/inspector --cli \
uvx --from $(CURDIR) mcp-chart-server --method resources/list
.PHONY: serve
serve: ## Run the server over stdio from the working tree
uv run mcp-chart-server
.PHONY: bump
bump: ## Add a reading from the shell (make bump VALUE=70)
uv run python scripts/chartctl.py bump $(VALUE)
.PHONY: reset
reset: ## Clear the chart and set the revision to zero
uv run python scripts/chartctl.py reset
.PHONY: show
show: ## Print the current revision and readings
uv run python scripts/chartctl.py show
.PHONY: open
open: ## Export the current chart to out/chart.png and open it in Preview
uv run python scripts/chartctl.py export out/chart.png
open out/chart.png
.PHONY: rebuild
rebuild: ## Invalidate the uvx build cache after editing the server
touch pyproject.toml
uvx --from $(CURDIR) mcp-chart-server --check
.PHONY: config
config: ## Render opencode.json for this checkout (project scope)
@sed 's#/absolute/path/to/updating-image-mcp-resource-macos#$(CURDIR)#' \
opencode.json.example > opencode.json
@echo "wrote opencode.json for $(CURDIR)"
.PHONY: desktop-config
desktop-config: ## Print the Claude Desktop entry to paste into its config
@printf ' "mcpServers": {\n "chart": {\n "command": "%s",\n "args": ["--from", "%s", "mcp-chart-server"]\n }\n }\n' \
"$$(command -v uvx)" "$(CURDIR)"
.PHONY: desktop-logs
desktop-logs: ## Tail Claude Desktop's log for this server (^C to stop)
tail -f "$$HOME/Library/Logs/Claude/mcp-server-$(SERVER).log"
.PHONY: add
add: ## Register the server with opencode (global config)
opencode mcp add $(SERVER) -- uvx --from $(CURDIR) mcp-chart-server
.PHONY: list
list: ## List configured MCP servers and their connection status
opencode mcp list
.PHONY: clean
clean: ## Remove caches and exported images
rm -rf .pytest_cache out dist **/__pycache__
Detailed breakdown
.DEFAULT_GOAL := helpmakes a baremakeprint the target list instead of running whatever sits at the top of the file. Recipe bodies must be tab-indented ormakeerrors.$(CURDIR)appears in five targets because every path handed touvxor to a client config must be absolute. Centralising it here means moving the checkout breaks nothing.configanddesktop-configrender rather than commit.configsubstitutes the placeholder inopencode.json.exampleinto a gitignoredopencode.json;desktop-configprints a block to paste, and resolvesuvxthroughcommand -vbecause GUI apps do not inherit your shell’sPATH.rebuildis thetouch pyproject.tomlfix from Step 7, followed by a--checkso you can see the rebuild happen instead of hoping it did.inspectandresourcesshell out tonpx, so they need Node where every other target needs onlyuv. Both pass$(CURDIR), which is why they work from any directory while the raw commands in Step 9 use$(pwd)and assume you are standing in the project.desktop-logshard-codes the macOS log path and interpolates$(SERVER), so the target keeps matching if the server is renamed — Claude Desktop names the file after themcpServerskey, not after the package.$$escapes to a single$for the shell — needed in thegreppattern, theawkblock, andcommand -v uvx.
Run it:
make
An updating image as an MCP resource - available targets:
help Show this help screen
install Sync runtime and dev dependencies
test Run the pytest suite
check Render the chart through the packaged entry point (no server)
smoke Drive the server over stdio and prove the image changes
inspect Open the MCP Inspector against this project (needs Node)
resources List the server's resources through the Inspector CLI
serve Run the server over stdio from the working tree
bump Add a reading from the shell (make bump VALUE=70)
reset Clear the chart and set the revision to zero
show Print the current revision and readings
open Export the current chart to out/chart.png and open it in Preview
rebuild Invalidate the uvx build cache after editing the server
config Render opencode.json for this checkout (project scope)
desktop-config Print the Claude Desktop entry to paste into its config
desktop-logs Tail Claude Desktop's log for this server (^C to stop)
add Register the server with opencode (global config)
list List configured MCP servers and their connection status
clean Remove caches and exported images
Launch command: uvx --from /Users/you/projects/updating-image-mcp-resource-macos mcp-chart-server
Step 14: Claude Desktop, and what a GUI client will not do
opencode proves the resource is delivered and lets a model act on it. Claude Desktop is the obvious next place to try, since a chat window that renders images should be where “did the chart update?” becomes a question you answer with your eyes.
It is also where this article’s server runs into the most instructive wall in it. Wiring up takes one config file; getting the picture on screen is a different matter, and the measurements below are the reason Step 6 has a tool fallback at all.
Print the entry and paste it into
~/Library/Application Support/Claude/claude_desktop_config.json:
make desktop-config
"mcpServers": {
"chart": {
"command": "/opt/homebrew/bin/uvx",
"args": ["--from", "/Users/you/projects/updating-image-mcp-resource-macos", "mcp-chart-server"]
}
}
The absolute uvx path is the part people get wrong. Claude Desktop is a GUI
application launched by Finder, so it does not inherit your shell’s PATH and a
bare "uvx" fails with a launch error that says nothing useful. command -v uvx
resolves it, which is why make desktop-config shells out rather than hard-coding
/opt/homebrew/bin/uvx.
Quit Claude Desktop completely (⌘Q, not just closing the window) and reopen it —
it reads the config only at launch. Then attach the resource from the controls row
at the bottom of the message composer: the + button to the left of the input
box, then an entry named after the server (chart), then Latest chart (PNG).
Some builds put MCP attachments behind a connectors or plug icon in that same row
instead. That label is the name= argument from Step 6, which is the one place
resource metadata is shown to a person rather than to a model, and the reason it
is worth writing something better than the function name.
That is the flow as documented, and it is the one part of this article that was
never made to happen. On 1.24012.9 the resource entry was not located in the
composer, and the server-side counter below is the reason to believe that is a
client limit rather than a missing click: across every session,
resources/read stayed at 0. Nothing in this step should be read as “attach
the chart, bump it, attach it again, and watch two revisions appear side by side.”
That is what the UI is for; it is not what happened here.
Read the Claude Desktop MCP logs
Claude Desktop writes one log per MCP server, plus a combined one, to
~/Library/Logs/Claude/. The per-server file is named after the key in
mcpServers, so this server’s is mcp-server-chart.log:
ls ~/Library/Logs/Claude/
main.log mcp-server-chart.log mcp.log
mcp-server-<name>.logis the one to read: the launch command, the server’s own stderr, and every JSON-RPC message in both directions.mcp.logis the combined connection-level log across all servers. It grows fast — several megabytes in a few sessions — so reach for the per-server file first.
Watch it live while Claude Desktop starts (make desktop-logs is this command):
tail -f ~/Library/Logs/Claude/mcp-server-chart.log
A successful launch opens with the resolved command and the PATH it searched:
[chart] [info] Initializing server...
[chart] [info] Using MCP server command: /opt/homebrew/bin/uvx with args and path: {
metadata: {
args: [ '--from', '/Users/you/projects/updating-image-mcp-resource-macos',
'mcp-chart-server', [length]: 3 ],
paths: [ '/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin', … ]
}
}
[chart] [info] Server started and connected successfully
That paths array is the answer to the PATH problem above: it is the entire
search path Claude Desktop has, and it is not your shell’s. If a bare "uvx" in
the config fails, this is where you see why.
Then the handshake, which is where the resource question gets settled:
[chart] [info] Message from client: {"method":"initialize",…,"clientInfo":{"name":"claude-ai","version":"0.1.0"}}
[chart] [info] Message from client: {"method":"tools/list","params":{},"jsonrpc":"2.0","id":1}
[chart] [info] Message from client: {"method":"prompts/list","params":{},"jsonrpc":"2.0","id":2}
[chart] [info] Message from client: {"method":"resources/list","jsonrpc":"2.0","id":3}
[chart] [info] Message from server: {"jsonrpc":"2.0","id":3,"result":{"resources":[
{"name":"Latest chart (PNG)","uri":"chart://latest.png",…,"mimeType":"image/png",…},
{"name":"Latest chart (SVG)","uri":"chart://latest.svg",…,"mimeType":"image/svg+xml",…},
{"name":"Chart state","uri":"chart://state",…,"mimeType":"application/json",…}]}}
Four calls at startup, and then nothing. Count the interesting methods across a whole session:
for m in resources/list resources/templates/list resources/read tools/call; do
printf '%-26s %s\n' "$m" "$(grep -c "$m" ~/Library/Logs/Claude/mcp-server-chart.log)"
done
resources/list 1
resources/templates/list 0
resources/read 0
tools/call 0
Two facts fall out of those zeros, and both are worth knowing before you design a server around resources:
resources/readwas never called. Not once, across a session spent asking the model to readchart://latest.png. The resource is listed, the metadata is correct, the bytes are ready — and no one asks for them. This is the evidence behind the next section: in Claude Desktop the read happens when a person attaches the resource, and a prompt cannot trigger it.resources/templates/listwas never called either. Claude Desktop asks for static resources only, sochart://window/{count}does not exist as far as that client is concerned. The MIME-type trap from Step 6 is real, but it is opencode and the Inspector that can reach the template at all.
The same grep is the first thing to run on any “my server is connected but nothing happens” report, whatever the server does. It separates the client never asked from the server answered badly, and those have completely different fixes.
When you cannot find the resource at all
Two limits meet here, and together they are the reason Step 6 has get_chart.
The attachment is a GUI flow with no documented location. It is a menu item
whose position has shifted across Claude Desktop releases, and no config key or
CLI reaches it. On 1.24012.9 the server connects and resources/list answers —
make smoke and make inspect prove the server half outright — while the
resource entry stays hard to find in the UI. The log tells you which half is at
fault, but it cannot tell you where the menu item went.
A prompt cannot reach a resource in Claude Desktop. Asking the model to “read
chart://latest.png and tell me the revision” does not work, and the zero
resources/read calls in the log above are what that failure looks like from the
server’s side: the request is never made. Resources go into a conversation when a
person attaches them. opencode’s read_mcp_resource (Step 12) is the contrast —
there, the same URI is one model-initiated call away.
Claude Code is the sharper contrast, because it is the same vendor. Measured
on Claude Code 2.1.220 against a separate probe server
(projects/python/mcp-image-display-clients-macos), a prompt naming a resource
URI produced resources/read 1 with tools/call 0, and the model
answered a question only the image could answer. Same company, same month, one
client that reaches resources from a prompt and one that cannot. Whatever you
conclude about resource reachability, scope it to a client and a version — a
vendor is not the unit that decides this.
So a prompt that is supposed to report the revision goes through the tools instead:
Call get_chart and get_chart_state, show me the chart, and tell me the revision.
Tools are listed in every client and callable by the model without a human in the loop, so this path does not depend on finding anything. Run against Claude Desktop 1.24012.9 it half works, and the two halves are worth separating.
The data arrives. The log records three tools/call requests, each answered
result(1 blocks) with no error, and the model reported the revision and the
readings correctly. For anything a text resource would have carried,
get_chart_state is a complete substitute for chart://state.
The picture never shows up on screen. get_chart returns ImageContent with
image/png — confirmed through the Inspector against the exact command in the
config — and the server logged a one-block result for that call too. What lands in
the conversation is a collapsed Get chart block. Expanding it shows no image.
Asking “can I see the chart?” produces another tool call, another block, and a
sentence describing the chart that the model could have written from
get_chart_state alone.
The obvious conclusion is that the image was dropped somewhere. It was not, and
the way to find out costs one question. Nothing this server sends as text names a
colour: get_chart_state returns a revision, a list of readings, a byte count, a
digest, and a file path, and the tool and resource descriptions say “bar chart”
and stop there. So ask something only the pixels can answer.
What colour is the bar?
Orange.
Which is correct, and not the guess a model would make about a bar chart. A lone
reading is also the newest one, so it is drawn in LATEST — (230, 120, 20) —
rather than the (48, 110, 200) blue every other bar gets. The block reached the
model intact. It is the transcript that shows nothing.
That puts the gap in a more awkward place than “the image was rejected”. On this client an image resource is unreachable from a prompt, an image tool is reachable and the model can act on what comes back, and neither route puts a picture in front of the person reading along. Three parties, and only two of them see it.
Which is fine, as long as you know which audience you are serving. If the model is
the consumer — deciding, summarising, comparing against earlier readings —
get_chart is sufficient here and needs nothing from the UI. If you are the
consumer, use the Inspector (Step 9) or opencode (Step 12), where the picture is
rendered rather than described. And resources remain the right shape for this
data: on a client that gives the model access to them they are the better path,
addressable and cacheable and free when unused. The tools are there so the server
does not depend on that.
Everything before this step is covered by make smoke and make test, so if the
attachment does not appear, the problem is the client or the config file, not the
server.
Why a chat client is a poor display path
Pull the measurements together and one design conclusion falls out. The MCP specification says a resource carries a MIME type and a body. It does not say the client must render either one. Everything about whether a picture appears on a screen lives above the protocol, in UI code that is undocumented, unversioned in any way you can query, and free to move between releases — as Claude Desktop’s attachment menu did.
So a server whose payoff is “the user sees the image in the chat” is betting on a feature nobody promised. Three separate client decisions have to go your way:
- Attach or drop the bytes. opencode checks the MIME type against five values and the size against 10 MiB. Miss either and the image is replaced by a sentence saying it was omitted.
- Give the model a way to ask. opencode ships
read_mcp_resource; Claude Desktop 1.24012.9 does not, which makes a resource there reachable only by a human clicking a menu. - Draw it. Neither chat client does, for either route.
A fourth decision waits behind those for anyone serving an image that changes. Re-reading a URI is also the client’s call: some cache a resource for the session, some fetch once and never again, some re-fetch only when a person reopens it. This server takes that off the table by making the state a file and the revision visible in the picture, so a stale read is at least detectable — but detecting it is all a server can do.
What to do instead, in the order I would reach for it:
- Aim the image at the model, not the screen. A tool returning
fastmcp.utilities.types.Imagereaches every client, is model-callable without a human in the loop, and is verified here to arrive intact. That is the one path that worked in all three clients. - Ship a text resource next to the image.
chart://statecosts a few hundred bytes and answers most of what anyone wanted the picture for, on models with no vision at all. - Point a human at a real viewer. The MCP Inspector (Step 9) renders what it
reads.
make openwrites the PNG and opens Preview. Both beat any chat window measured here, and neither involves a model. - If a person really must see it from a conversation, serve a link, not bytes. A URL to something a browser can open sidesteps every rule above. Serve Media from a FastMCP Server on macOS covers the expiring-link pattern.
What about ChatGPT?
Untested here, and the honest answer is that nobody should guess. The tempting
argument is that ChatGPT already renders uploaded images and images it generates,
so an MCP image would just flow into the same pipeline. Claude Desktop is the
counterexample that kills that reasoning: it renders uploaded images and images it
generates too, and it still shows nothing for an ImageContent block returned by
a tool. An existing image pipeline says nothing about whether MCP content is
routed into it.
Treat it as a measurement waiting to be taken, and take it the same way this article took the others:
- Count the methods the client actually calls on the server side —
resources/list,resources/read,tools/call. That separates “the client never asked” from “the server answered badly”, and it is the check that settled Claude Desktop. - Ask the model something only the pixels answer.
What colour is the bar?costs one question and tells you whether the block reached the model, which is a different question from whether it reached your eyes.
Until that run exists, the table at the top of this article is the whole of what is known: one client draws the picture, and it is not a chat client.
Troubleshooting
✗ chart failed with MCP error -32000: Connection closed. The process died
before the handshake. Run the launch command by hand — uvx --from <path> mcp-chart-server --check — where the real error goes to your terminal instead of
being collapsed into one status line.
You fixed the server and nothing changed. The uvx build cache. make rebuild, or touch pyproject.toml. See the table in Step 7: --refresh,
--refresh-package, and uv cache clean <pkg> do not help.
[Binary MCP resource omitted: … is not a supported attachment type]. The
MIME type is not one of opencode’s five (application/pdf, image/gif,
image/jpeg, image/png, image/webp). If you are reading a template, the
likely cause is the FastMCP behaviour from Step 6: return
ResourceResult([ResourceContent(png, mime_type="image/png")]), not bare bytes.
[Binary MCP resource omitted: … exceeds 10 MB]. The cap is 10485760 bytes —
10 MiB, which opencode’s message rounds off as 10 MB — measured on the decoded
bytes, not on the base64 length. Downscale, or serve a link and let the client
fetch it — Serve Media from a FastMCP Server on macOS
covers the expiring-link pattern for payloads too big to inline.
The model reports it cannot interpret images. The resource arrived; the model
or the provider path cannot use it. Check the stored tool result for attachments: 1, then try a vision-capable model on a first-class provider, or Step 14.
The image never appears in the chat window. Expected, in both chat clients
measured here — see the table at the top and Step 14. Before changing anything,
establish which half is missing: check the server log for a tools/call or
resources/read that was answered without an error, then ask the model “what
colour is the bar?”. A correct answer means the bytes reached the model and only
the UI omitted them, which is not a bug you can fix from the server. Use the
Inspector or make open when you need to look at the picture yourself.
The client connects but the model never sees the resource. Read
~/Library/Logs/Claude/mcp-server-chart.log (or make desktop-logs) and grep for
resources/read. No matches means the client never asked, which is a client
problem, not a server one: either it has no resource-reading tool — Claude Desktop
1.24012.9, per Step 14 — or the model was never told the resource exists. For the
first, call get_chart and get_chart_state; for the second, name the server and
the URI in the prompt. Try the second before concluding the first: naming the URI
outright is what produced a resources/read on Claude Code 2.1.220, and the two
failures look identical from the server until you have ruled it out.
❌ Proxy Server PORT IS IN USE at port 6277 ❌. An Inspector is already
running. Close the other one, or move both ports:
CLIENT_PORT=6284 SERVER_PORT=6287 make inspect.
The Inspector shows stale behaviour after an edit. Same uvx build cache as
everything else — the Inspector spawns the server through the same command. make rebuild, then reconnect.
The revision never changes. The client and your shell are reading different
state files. Every chartctl.py command prints the path it used, and
chart://state reports state_file; compare the two. A CHART_STATE set in one
environment and not the other is the usual cause.
The chart is empty after a client restart. It should not be — state is a file.
If it is, the server is writing somewhere unexpected, which again means checking
state_file in chart://state.
Notes on serving images as resources
- Set
mime_typeexplicitly on every binary resource. Clients dispatch on it. Omitting it gets youapplication/octet-stream, which nothing will render. - Keep images small and know your client’s cap. opencode’s is 10 MiB decoded. A chart at 240x120 is under 500 bytes; a screenshot is megabytes. If the picture is large, serve a link rather than a blob.
- PNG, JPEG, GIF, and WebP are the safe formats. SVG is not, in any client that filters by MIME type — it is markup, and it travels as text. Rasterize it server-side if the client needs to see it.
- Make the image self-identifying. A revision, a timestamp, a version — some mark that changes when the data changes. Without it, no one downstream can tell a stale read from a fresh one.
- Pair the image with a text resource.
chart://statecosts almost nothing and gives text-only models something to work with, gives you a digest to compare, and turns “the picture looks wrong” into a question about specific numbers. - Check the client’s log before you change the server. A per-server log that
shows
resources/listanswered andresources/readnever called is telling you the server is fine and the client never asked. Without that check the obvious move is to start editing MIME types and return values, which fixes nothing. - Mirror the important resources as tools. Resource access is client-initiated
and unevenly implemented; tool calls are model-initiated and reach every client.
A two-line tool that returns
Imageover the same renderer costs nothing. Keep both routes on one implementation so they cannot drift, and test that they produce identical bytes. - Delivering an image is not the same as displaying one. A client can call
your tool, pass a correct
ImageContentblock to the model, and still show the person reading nothing at all — Step 14 measures exactly that, and separates the two by asking the model a question only the pixels can answer. Decide which audience you are serving before you conclude anything is broken. - Do not design around a chat client displaying it. Of the three clients measured here, only the MCP Inspector draws the picture. Rendering is a UI decision the specification does not require, and it has already moved between Claude Desktop releases. Build for the model, and send a human to a viewer or a link.
- Keep a question the picture alone can answer. A colour, a shape, a mark that appears in no field of your JSON. It costs nothing and it is the only way to tell “the model never got the image” from “the client never drew it”, which have entirely different fixes.
- Subscriptions are still not first-class. FastMCP 3.4.4 advertises
resources.subscribe=false, and opencode re-reads on demand rather than subscribing, so there is nothing to push to. Serve Resources Well from an MCP Server on macOS covers the low-level handlers for clients that do subscribe.
Recap
The server serves a real image: a bar chart encoded by 40 lines of zlib and
struct, rendered on every read from state that a tool call or a shell command
can change, delivered as a base64 blob with image/png on it. uvx --from <dir>
runs it straight from a local directory with no publishing step, and opencode
launches it, reads it, and hands the model an attachment that is byte-identical to
what the renderer produced.
Three things along that path are easy to get wrong and hard to diagnose, and each
one is pinned by a test or a measured table above: FastMCP drops a template’s MIME
type unless you wrap the bytes, opencode silently omits any binary resource whose
MIME type is outside its five, and uvx will happily run yesterday’s build until
pyproject.toml is touched.
A fourth is not a bug in anything: whether a model can reach a resource at all is
the client’s decision, and clients disagree. get_chart and get_chart_state
mirror the two resources that matter as tools, over the same renderer and pinned
to the same bytes by a test, so the server is useful even where resources are a
manual attachment.
And the finding that should shape what you build next: of the three clients measured, only the MCP Inspector puts the image on screen. opencode and Claude Desktop both hand it to the model and show you a line of text. That is not a protocol problem and it is not fixable from the server, so the picture is for the model, and you should look at it in the Inspector or in Preview.
Next improvements:
- Run the two measurements above against a ChatGPT MCP connector and add a fourth row to the table — method counts on the server side, then the colour question. It is the one client people ask about that this article cannot answer.
- Rasterize the SVG server-side and serve both, so clients that render markup and clients that only take blobs are both satisfied.
- Add a
chart://compare/{a}/{b}template that renders two revisions side by side, turning “did it change?” into a single read. - Back the readings with a real source — CPU load, a queue depth, a build timer — and let the picture be worth looking at.
- Publish the package to PyPI so the launch command drops the
--fromand the cache problem with it. Publish a FastMCP Server to PyPI and Run It Anywhere with uvx covers that path.