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.
What you will build
updating-image-mcp-resource-macos/
├── .gitignore
├── pyproject.toml # packaged: [project.scripts] is what uvx runs
├── Makefile # help / test / smoke / bump / config / add
├── 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 |
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.
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.
"""
from __future__ import annotations
import hashlib
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.resources import ResourceContent, ResourceResult
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."
),
)
@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 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 tools re-load state from disk before mutating. The server keeps nothing
in memory, so a
make bumpbetween two tool calls is picked up rather than overwritten.
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")
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.
Run them:
uv run pytest -q
......................... [100%]
25 passed in 0.35s
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")
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.
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
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.
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.
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 |
When you need to confirm the picture itself is fine, a vision-capable model on a first-class provider is the check — or Step 14’s Claude Desktop route, where you can look at it yourself.
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: 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: 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.$$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
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
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, when you want to look at the picture
opencode proves the resource is delivered and lets a model act on it. Claude Desktop is where you can see it: it renders an attached image resource in the conversation, so “did the chart update?” becomes a question you answer with your eyes.
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 attachment
control next to the message box, pick the chart server, and choose
Latest chart (PNG). 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.
To watch it update: attach the chart, run make bump VALUE=90 in a terminal, and
attach it again. Two images in the same conversation, one revision apart.
This step is the only part of this article that is not scriptable — it is a GUI
flow, and the labels move between Claude Desktop releases. Everything up to it is
covered by make smoke and make test, so if the attachment does not appear, the
problem is almost certainly the config file rather than the server.
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 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. - 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.
Next improvements:
- 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.