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:

URIMIMEBodyWhy it is here
chart://latest.pngimage/pngblobThe headline: the picture, redrawn per read
chart://window/{count}image/pngblobA template, and the MIME-loss trap
chart://latest.svgimage/svg+xmltextWhat a client does with a format it will not attach
chart://stateapplication/jsontextThe 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 with brew install uv or from docs.astral.sh/uv.
  • Python 3.12 or later. uv will 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.json is 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.example is committed instead, and make config renders 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 with uvx. The name on the left, mcp-chart-server, is the command; mcp_chart_server:main is the callable it invokes. Everything else in this article assumes that pairing.
  • --package gives the src layout (src/mcp_chart_server/), which keeps the installed package and the working tree from shadowing each other during tests.
  • fastmcp is the only runtime dependency. Rendering happens in the standard library, so nothing else follows the server into a client’s environment.
  • .python-version is removed so uv resolves an interpreter from requires-python instead 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.py re-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.rect clips 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 12 legibly. 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_svg draws the same chart in markup. It exists to make one point concrete in Step 9: an SVG is text, so it travels as text rather than blob, 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_STATE makes the server testable. Every test in Step 8 points it at a tmp_path, so the suite never touches the real chart and tests cannot interfere with each other.
  • load swallows 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.
  • save writes to a temp file and renames. Path.replace is 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 — which load would then silently treat as an empty chart.
  • append refuses 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 what test_out_of_range_reading_is_refused pins down.
  • MAX_HISTORY bounds 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 bytes is the whole trick for a binary resource. FastMCP sends a bytes return as a BlobResourceContents with a base64 blob field and no text field. Do not base64-encode by hand; you will end up with the encoding applied twice and a client that reports a corrupt image.
  • mime_type is not decoration. It is the field clients filter on. Step 9 shows opencode refusing the exact same PNG bytes when they arrive labelled application/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, not bytes. FastMCP 3.4.4’s ResourceTemplate.convert_result calls ResourceResult(raw_value) without passing the template’s MIME type, while the equivalent method on a static resource passes mime_type=self.mime_type. The result: a template returning bytes advertises image/png in resources/templates/list and then delivers application/octet-stream on read. Wrapping the bytes in ResourceContent(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: int is 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://state carries 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 bump between 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

  • --check renders without serving. A stdio server blocks forever by design, which makes “is this installed correctly?” awkward to answer. --check prints 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 server import is inside main. Importing it at module scope would build the FastMCP instance during --check for no reason, and it keeps a broken server module from breaking the check that would diagnose it.
  • main returns an int and the generated console script wraps it in sys.exit, so uvx --from . mcp-chart-server --check sets 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:

ActionPicks 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-serverno
touch pyproject.tomlyes

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 = auto runs 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

  • chunks is 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_scanlines decompresses 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_chart is 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_ATTACHABLE encodes 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_text asserts the absence of text as well as the presence of blob, because that pair is how a client decides which branch to take.
  • test_adding_a_reading_changes_the_image is the article’s core claim, reduced to four lines: read, mutate, read, compare.
  • test_revision_survives_a_reconnect is the payoff of file-backed state. A client that reconnects — which opencode does on every opencode run — sees the same chart.
  • test_template_keeps_its_mime_type is a regression test for the FastMCP behaviour in Step 6. Drop the ResourceContent wrapper and it fails.
  • The autouse scratch_state fixture redirects CHART_STATE per 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 --script build a throwaway environment containing the official mcp SDK. Nothing is installed into the project.
  • COMMAND and ARGS mirror 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_STATE points 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_sha is 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_sha256 has 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 text and the absence of blob. 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.
  • export writes 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_STATE set.

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 a url instead of a command.
  • command is an array, not a string. No shell is involved, so quoting and PATH expansion do not apply, and uvx must 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 config in Step 13, which writes a gitignored opencode.json for 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:

ToolWhat it does
list_mcp_resourcesLists resources across connected servers, or one named server
list_mcp_resource_templatesThe same for templates
read_mcp_resourceReads 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.

LayerFailure looks like
Server MIME type[Binary MCP resource omitted: … is not a supported attachment type]
Client attachment rulesSame message, or one about the 10 MiB cap
Model or providerThe 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 := help makes a bare make print the target list instead of running whatever sits at the top of the file. Recipe bodies must be tab-indented or make errors.
  • $(CURDIR) appears in five targets because every path handed to uvx or to a client config must be absolute. Centralising it here means moving the checkout breaks nothing.
  • config and desktop-config render rather than commit. config substitutes the placeholder in opencode.json.example into a gitignored opencode.json; desktop-config prints a block to paste, and resolves uvx through command -v because GUI apps do not inherit your shell’s PATH.
  • rebuild is the touch pyproject.toml fix from Step 7, followed by a --check so you can see the rebuild happen instead of hoping it did.
  • $$ escapes to a single $ for the shell — needed in the grep pattern, the awk block, and command -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_type explicitly on every binary resource. Clients dispatch on it. Omitting it gets you application/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://state costs 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 --from and the cache problem with it. Publish a FastMCP Server to PyPI and Run It Anywhere with uvx covers that path.