A resource that returns a picture is the most demanding shape an MCP resource takes. It has to survive base64 encoding, arrive with a MIME type the client will accept, stay small enough to attach, and — if the picture is meant to reflect something live — return different bytes the next time the same URI is read.

Serve Resources Well from an MCP Server on macOS covered the shapes of resources with an eight-byte PNG magic number standing in for a binary body. This article replaces that stub with a real image: a bar chart rendered on every read from state that anything can change, packaged so uvx runs it from a local directory, and wired into opencode so a model can read it, watch it change, and read it again.

Two findings from that wiring drive most of the design below. opencode 1.18.5 attaches a binary resource only if its MIME type is one of five, and FastMCP 3.4.4 drops the declared MIME type on resource templates — which turns a working image into one the client silently refuses. Both are demonstrated rather than asserted.

A third finding outranks both, and it is the one to read before you design anything around an image resource.

Read this first: only one client here draws the picture

Three clients were measured against this server. All three load the image. One of them puts it on your screen.

ClientGets the bytesDraws the picture for you
MCP Inspector 1.0.0Yes — resources/read and tools/call bothYes. Rendered in the Resources and Tools tabs
opencode 1.18.5Yes — attached to the model as image/png, byte-identical to the renderNo. The terminal prints a tool-call line and a stored result reading [Binary MCP resource attached: …]
Claude Desktop 1.24012.9Yes, through the get_chart tool. The resource is not reachable from a prompt at allNo. A collapsed Get chart block, empty when expanded

Nothing in that table is a server bug, and none of it is fixable from the server. The bytes leave correctly every time: 465 bytes of image/png with SHA-256 1786bcf09b2b62e0…, the same digest from four independent paths. What each client does next is its own decision, and two of these three decide to show a person nothing.

The model is better served than you are. In Claude Desktop the ImageContent block reaches the model intact — asked what colour the bar was, it answered orange, which appears nowhere in any text this server sends (Step 14). In opencode the attachment reaches the model too, and whether the model can use it then depends on the model and the provider (Step 12). So three parties see this image differently:

  • The server sends it. Provable by test.
  • The model receives it. Provable by asking a question only the pixels answer.
  • The person reading along sees it in the MCP Inspector, and nowhere else here.

Two consequences run through the rest of this article. Build the server as if the model is the audience, because that is the audience you can actually reach — which is why Step 6 mirrors the two headline resources as tools. And when you need to look at the picture, use the Inspector (Step 9) or open the exported PNG (make open), not a chat window. “Ask a chat client to load my image and show it to me” is not a workflow any client here supports today; the closing section Why a chat client is a poor display path covers what to do instead, and where ChatGPT sits as an open question.

What you will build

updating-image-mcp-resource-macos/
├── .gitignore
├── pyproject.toml                 # packaged: [project.scripts] is what uvx runs
├── Makefile                       # help / test / smoke / inspect / bump / config
├── opencode.json.example          # project-scoped MCP entry, path templated
├── src/mcp_chart_server/
│   ├── __init__.py                # console-script entry, with a --check smoke
│   ├── png.py                     # a PNG encoder in zlib + struct, no deps
│   ├── chart.py                   # canvas, 3x5 digit font, bar renderer
│   ├── state.py                   # file-backed revision + readings
│   └── server.py                  # the FastMCP resources and tools
├── scripts/
│   ├── smoke_test.py              # stdio client: read, mutate, re-read, compare
│   └── chartctl.py                # change the chart with no MCP client involved
└── tests/
    ├── test_png.py                # encoder structure, CRCs, determinism
    └── test_server.py             # wire content: blob vs text, MIME, updates

The server ends up with four resources over one piece of state:

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

Plus four tools. Two of them mutate the state — add_reading and reset_chart — and two, get_chart and get_chart_state, return the same bytes as the two headline resources. That duplication is deliberate: a resource is fetched by the client, a tool is called by the model, and some clients never give the model a way to do the former. Step 6 covers why, Step 14 covers the client that forced it.

Prerequisites

  • macOS. Validated on macOS 26.5.2 (Apple silicon). The server side works unchanged on Linux.
  • uv 0.5 or later, which provides uvx. Validated on uv 0.11.26. Install 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.
  • Node 18 or later, for npx — needed only by the MCP Inspector in Step 9 (make inspect, make resources). Nothing else in the project uses it. Validated with the Inspector at 1.0.0.

No image library. The PNG encoder is 40 lines of standard library, which keeps the package’s runtime dependency list at exactly one entry.

Step 1: Add project hygiene

Create the file

mkdir -p updating-image-mcp-resource-macos
cd updating-image-mcp-resource-macos
touch .gitignore

Add the code: .gitignore

# Python
__pycache__/
*.py[cod]
.venv/
venv/

# uv / build
.uv-cache/
dist/
*.egg-info/

# Test + tooling caches
.pytest_cache/
.ruff_cache/

# Generated chart state and exported images
chart-state.json
out/

# opencode local state
.opencode/

# macOS
.DS_Store

# Rendered by `make config` (contains an absolute path)
opencode.json

Detailed breakdown

  • Written before anything else, so no generated artifact is ever tracked.
  • out/ holds PNGs the smoke test exports; dist/ holds wheels. Both are build output, not source.
  • opencode.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.

Two more tools, `get_chart` and `get_chart_state`, mirror the two headline
resources. Resources have to be handed to the model by the client; tools are
callable by the model itself. Clients that give the model no way to read a
resource — Claude Desktop 1.24012.9 among them — can still reach the same bytes
through the tool.
"""

from __future__ import annotations

import hashlib

from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.resources import ResourceContent, ResourceResult
from fastmcp.utilities.types import Image

from .chart import MAX_BARS, render_chart, render_svg
from .state import ChartState, state_path

mcp = FastMCP(
    "chart",
    instructions=(
        "Serves a bar chart of recent readings. Read chart://latest.png for the "
        "picture and chart://state for the numbers. add_reading changes both. "
        "If you cannot read resources, call get_chart and get_chart_state instead."
    ),
)


@mcp.resource(
    "chart://latest.png",
    name="Latest chart (PNG)",
    description="Bar chart of the most recent readings, labelled with the current revision.",
    mime_type="image/png",
    tags={"chart", "image"},
)
def latest_png() -> bytes:
    """The image resource. Returning `bytes` is what makes this a blob."""
    state = ChartState.load()
    return render_chart(state.readings, state.revision)


@mcp.resource(
    "chart://window/{count}",
    name="Chart window",
    description="The chart drawn over the last `count` readings (1-12).",
    mime_type="image/png",
    tags={"chart", "image"},
)
def chart_window(count: int) -> ResourceResult:
    """A template resource: the URI selects how much history to draw.

    Note the return type. A template that returns bare `bytes` loses its declared
    MIME type on the way out (FastMCP 3.4.4 sends `application/octet-stream`),
    and a client that filters attachments by MIME will drop the image. Wrapping
    the bytes in `ResourceContent` with an explicit `mime_type` is what keeps
    `image/png` on the wire.
    """
    if not 1 <= count <= MAX_BARS:
        raise ToolError(f"count must be between 1 and {MAX_BARS}, got {count}")
    state = ChartState.load()
    png = render_chart(state.readings[-count:], state.revision)
    return ResourceResult([ResourceContent(png, mime_type="image/png")])


@mcp.resource(
    "chart://latest.svg",
    name="Latest chart (SVG)",
    description="The same chart as SVG. Most clients will not attach this one.",
    mime_type="image/svg+xml",
    tags={"chart", "image"},
)
def latest_svg() -> str:
    """Text, not bytes: an SVG is markup, so it travels as `text`, not `blob`."""
    state = ChartState.load()
    return render_svg(state.readings, state.revision)


@mcp.resource(
    "chart://state",
    name="Chart state",
    description="Revision, readings, and the SHA-256 of the current PNG.",
    mime_type="application/json",
    tags={"chart"},
)
def chart_state() -> dict:
    """The numbers behind the picture, plus a digest a caller can compare across
    reads to prove the image actually changed."""
    state = ChartState.load()
    png = render_chart(state.readings, state.revision)
    return {
        "revision": state.revision,
        "readings": state.readings,
        "png_bytes": len(png),
        "png_sha256": hashlib.sha256(png).hexdigest(),
        "state_file": str(state_path()),
    }


@mcp.tool
def get_chart() -> Image:
    """Return the current chart as a PNG image.

    The same bytes as `chart://latest.png`, reached by a route the model can take
    on its own. `Image` is what makes this arrive as MCP `ImageContent` rather
    than as base64 in a text block.
    """
    state = ChartState.load()
    return Image(data=render_chart(state.readings, state.revision), format="png")


@mcp.tool
def get_chart_state() -> dict:
    """Return the revision, the readings, and the digest of the current PNG."""
    return chart_state()


@mcp.tool
def add_reading(value: int) -> dict:
    """Append a reading (0-100) to the chart and bump its revision."""
    state = ChartState.load()
    try:
        state.append(value)
    except ValueError as exc:
        raise ToolError(str(exc)) from None
    state.save()
    return {"revision": state.revision, "readings": state.readings}


@mcp.tool
def reset_chart() -> dict:
    """Clear every reading and set the revision back to zero."""
    state = ChartState.load().reset()
    state.save()
    return {"revision": state.revision, "readings": state.readings}

Detailed breakdown

  • Returning 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 mutating tools re-load state from disk before writing. The server keeps nothing in memory, so a make bump between two tool calls is picked up rather than overwritten.
  • get_chart returns Image, not bytes. A tool returning raw bytes gets serialized into a text block full of base64. fastmcp.utilities.types.Image wraps the bytes into MCP ImageContent with mimeType: image/png, which is the shape a client renders. format="png" is what sets that MIME type — omit it and you get the class default, which happens to be image/png here but is not something to rely on.
  • get_chart_state calls chart_state() directly. FastMCP’s @mcp.resource decorator returns the undecorated function, so the resource body is still an ordinary callable. One implementation, two entry points, no chance of the tool and the resource drifting apart.

Why the same data is exposed twice

Resources and tools differ in who initiates the read. A resource is something the client fetches and hands to the model — through a UI attachment control, an @-mention, or a built-in tool the client provides. A tool is something the model calls on its own.

That distinction stays invisible until a client makes the first path the only path. In Claude Desktop 1.24012.9 the resource is reached by a human clicking through the composer’s attachment menu — there is no prompt you can write that makes the model fetch it, and the menu itself has moved between releases (Step 14 has the details and what happened when this article’s server was wired into it). opencode, by contrast, gives the model a read_mcp_resource tool (Step 12), so the same resource is model-reachable there. Same server, same resources/list response, completely different reach.

So the four resources stay as they are, and two tools mirror the two that matter:

ResourceMirrored bySame bytes?
chart://latest.pngget_chartYes — one render_chart call, verified by test
chart://stateget_chart_stateYes — the tool calls the resource function

Tools are listed in every MCP client and are model-callable everywhere, so this is the surface that works no matter what is on the other end. Resources remain the better shape for the data — they are addressable, cacheable, and cost nothing when unused — but a resource a model cannot reach is a resource that does not exist.

How wide that gap is, measured rather than assumed:

ClientModel can reach a resource from a prompt?
opencode 1.18.5Yes — read_mcp_resource (Step 12)
Claude Code 2.1.220Yes — a prompt naming the URI produced resources/read 1
Claude Desktop 1.24012.9No — resources/read 0 across every session (Step 14)

The two Anthropic clients disagree with each other, which is the useful part: this is a per-client, per-version property, not something you can infer from who ships it. The tools are here so the server does not have to care which one it meets.

Step 7: The console-script entry point

Create the file

touch src/mcp_chart_server/__init__.py

Add the code: src/mcp_chart_server/__init__.py

"""Console-script entry point for `mcp-chart-server`.

This is the callable named in `[project.scripts]`, so it is what `uvx` runs. The
`--check` flag renders the chart and exits without serving, which turns "did the
package install correctly" into a question you can answer in under a second.
"""

from __future__ import annotations

import sys

from .chart import render_chart
from .state import ChartState, state_path

__all__ = ["main"]


def main(argv: list[str] | None = None) -> int:
    args = sys.argv[1:] if argv is None else argv

    if "--check" in args:
        state = ChartState.load()
        png = render_chart(state.readings, state.revision)
        print(f"state file : {state_path()}")
        print(f"revision   : {state.revision}")
        print(f"readings   : {state.readings}")
        print(f"png        : {len(png)} bytes, magic {png[:8]!r}")
        return 0

    from .server import mcp

    mcp.run()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Detailed breakdown

  • --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")


async def test_the_tool_fallback_is_listed():
    """The tools a model can call without the client handing it a resource."""
    async with Client(mcp) as client:
        names = {tool.name for tool in await client.list_tools()}
        assert names == {"add_reading", "get_chart", "get_chart_state", "reset_chart"}


async def test_get_chart_returns_image_content():
    """A tool returning `Image` produces MCP ImageContent, not base64 in text."""
    async with Client(mcp) as client:
        result = await client.call_tool("get_chart", {})
        content = result.content[0]
        assert content.type == "image"
        assert content.mimeType == "image/png"
        assert base64.b64decode(content.data).startswith(b"\x89PNG\r\n\x1a\n")


async def test_get_chart_matches_the_resource_byte_for_byte():
    """The fallback is the same picture, not a second rendering path."""
    async with Client(mcp) as client:
        for value in (30, 55, 20, 80, 45):
            await client.call_tool("add_reading", {"value": value})
        via_tool = base64.b64decode((await client.call_tool("get_chart", {})).content[0].data)
        via_resource = base64.b64decode(
            (await client.read_resource("chart://latest.png"))[0].blob
        )
        assert via_tool == via_resource == render_chart([30, 55, 20, 80, 45], 5)


async def test_get_chart_state_matches_the_state_resource():
    async with Client(mcp) as client:
        await client.call_tool("add_reading", {"value": 42})
        via_tool = (await client.call_tool("get_chart_state", {})).data
        via_resource = json.loads((await client.read_resource("chart://state"))[0].text)
        assert via_tool == via_resource
        assert via_tool["revision"] == 1


async def test_get_chart_tracks_an_update():
    """Same call, different bytes: the fallback is as live as the resource."""
    async with Client(mcp) as client:
        before = (await client.call_tool("get_chart", {})).content[0].data
        await client.call_tool("add_reading", {"value": 42})
        after = (await client.call_tool("get_chart", {})).content[0].data
        assert before != after

Detailed breakdown

  • OPENCODE_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.
  • test_get_chart_matches_the_resource_byte_for_byte is what keeps the fallback honest. Two entry points to one picture is only safe while they stay identical; this test fails the moment they do not.
  • test_get_chart_returns_image_content checks content.type == "image", which is the difference between a rendered picture and a wall of base64 in a text block. Returning bytes from the tool instead of Image produces the latter and fails here.

Run them:

uv run pytest -q
..............................                                           [100%]
30 passed in 0.39s

Step 9: Prove it over stdio, with no model in the loop

A model in the loop makes a failure ambiguous — the server, the launch command, or the model choosing not to call anything. A direct stdio client removes two of those, and it runs the exact command the MCP client will run.

Create the file

mkdir -p scripts
touch scripts/smoke_test.py

Add the code: scripts/smoke_test.py

# /// script
# requires-python = ">=3.12"
# dependencies = ["mcp>=1.9.0"]
# ///
"""Drive mcp-chart-server over stdio with the exact command an MCP client uses.

No model is involved. This proves three things in order:

1. `uvx --from <project> mcp-chart-server` launches and completes the handshake.
2. `chart://latest.png` comes back as a base64 blob with `image/png`, and the
   bytes really are a PNG.
3. Calling `add_reading` changes the image: same URI, different digest.

Exported PNGs land in `out/` so you can open them and see the revision counter
move.

Usage:
    uv run --script scripts/smoke_test.py [/abs/path/to/project]
"""

from __future__ import annotations

import asyncio
import base64
import hashlib
import json
import os
import sys
import tempfile
from pathlib import Path

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

PROJECT = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parent.parent
OUT = PROJECT / "out"

# Must match the "command" array in opencode.json.
COMMAND = "uvx"
ARGS = ["--from", str(PROJECT), "mcp-chart-server"]


def digest(blob: str) -> tuple[bytes, str]:
    raw = base64.b64decode(blob)
    return raw, hashlib.sha256(raw).hexdigest()


async def main() -> int:
    OUT.mkdir(exist_ok=True)
    # A scratch state file keeps the run reproducible and leaves the real one alone.
    with tempfile.TemporaryDirectory() as tmp:
        env = {**os.environ, "CHART_STATE": str(Path(tmp) / "state.json")}
        params = StdioServerParameters(command=COMMAND, args=ARGS, env=env)
        async with stdio_client(params) as (read, write):
            async with ClientSession(read, write) as session:
                init = await session.initialize()
                print(f"server     : {init.serverInfo.name} {init.serverInfo.version}")
                print(f"resources  : {init.capabilities.resources is not None}")

                listed = sorted(str(r.uri) for r in (await session.list_resources()).resources)
                print(f"static     : {listed}")
                assert listed == [
                    "chart://latest.png",
                    "chart://latest.svg",
                    "chart://state",
                ], listed

                templates = [
                    t.uriTemplate
                    for t in (await session.list_resource_templates()).resourceTemplates
                ]
                print(f"templates  : {templates}")
                assert templates == ["chart://window/{count}"], templates

                # Read 1: the empty chart.
                content = (await session.read_resource("chart://latest.png")).contents[0]
                assert content.mimeType == "image/png", content.mimeType
                assert getattr(content, "text", None) is None, "a PNG must not arrive as text"
                first, first_sha = digest(content.blob)
                assert first.startswith(b"\x89PNG\r\n\x1a\n"), first[:8]
                (OUT / "chart-before.png").write_bytes(first)
                print(f"read 1     : {len(first)} bytes  sha256={first_sha[:16]}...")

                # Change the state through a tool, the way a model would.
                for value in (30, 55, 20, 80, 45):
                    result = await session.call_tool("add_reading", {"value": value})
                state = json.loads(result.content[0].text)
                print(f"add_reading: revision={state['revision']} readings={state['readings']}")
                assert state["revision"] == 5, state

                # Read 2: same URI, new picture.
                content = (await session.read_resource("chart://latest.png")).contents[0]
                second, second_sha = digest(content.blob)
                (OUT / "chart-after.png").write_bytes(second)
                print(f"read 2     : {len(second)} bytes  sha256={second_sha[:16]}...")
                assert second_sha != first_sha, "the image did not change"

                # The JSON resource agrees with the bytes it describes.
                content = (await session.read_resource("chart://state")).contents[0]
                described = json.loads(content.text)
                assert described["png_sha256"] == second_sha, described
                assert described["revision"] == 5, described
                print(f"state      : revision={described['revision']} png={described['png_bytes']}B")

                # The template narrows the window without touching the state.
                content = (await session.read_resource("chart://window/2")).contents[0]
                window, _ = digest(content.blob)
                (OUT / "chart-window-2.png").write_bytes(window)
                assert content.mimeType == "image/png"
                print(f"window/2   : {len(window)} bytes")

                # SVG travels as text, not as a blob. Clients treat the two differently.
                content = (await session.read_resource("chart://latest.svg")).contents[0]
                assert content.mimeType == "image/svg+xml", content.mimeType
                assert content.text.startswith("<svg "), content.text[:40]
                assert getattr(content, "blob", None) is None
                print(f"svg        : {len(content.text)} chars of text, no blob")

                # The tool fallback, for clients that give the model no way to
                # read a resource. Same bytes, reached by a model-callable route.
                image = (await session.call_tool("get_chart", {})).content[0]
                assert image.type == "image", image.type
                assert image.mimeType == "image/png", image.mimeType
                tool_png, tool_sha = digest(image.data)
                assert tool_sha == second_sha, (tool_sha, second_sha)
                print(f"get_chart  : {len(tool_png)} bytes  sha256={tool_sha[:16]}... (same)")

    print(f"\nPASS - the image changed after add_reading. PNGs written to {OUT}")
    return 0


if __name__ == "__main__":
    sys.exit(asyncio.run(main()))

Detailed breakdown

  • The PEP 723 header lets uv run --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.
  • The get_chart call compares its digest against read 2. The tool and the resource have to be the same picture over the real transport, not just in the in-memory tests, because the tool is what a client without resource access will be using.

Run it:

uv run --script scripts/smoke_test.py
server     : chart 3.4.4
resources  : True
static     : ['chart://latest.png', 'chart://latest.svg', 'chart://state']
templates  : ['chart://window/{count}']
read 1     : 373 bytes  sha256=f1dfbaf41d2e975e...
add_reading: revision=5 readings=[30, 55, 20, 80, 45]
read 2     : 465 bytes  sha256=1786bcf09b2b62e0...
state      : revision=5 png=465B
window/2   : 420 bytes
svg        : 531 chars of text, no blob
get_chart  : 465 bytes  sha256=1786bcf09b2b62e0... (same)

PASS - the image changed after add_reading. PNGs written to .../out

open out/chart-before.png out/chart-after.png shows the two images: an empty frame labelled REV 0, and five bars labelled REV 5 with the last one orange.

The FastMCP banner appears on stderr alongside this output. That is the server announcing itself, not an error.

Look at it in the MCP Inspector — the only place the picture is drawn

The smoke test asserts; the MCP Inspector lets you click. It is also, of the three clients in this article, the only one that renders the image rather than describing it or naming it, so it is worth setting up before opencode and long before Claude Desktop. Treat it as the display surface for this server and the chat clients as delivery paths to a model.

It speaks the same stdio protocol, so it takes the same launch command — run it from the project directory and $(pwd) supplies the absolute path every client config needs:

npx @modelcontextprotocol/inspector uvx --from "$(pwd)" mcp-chart-server
Starting MCP inspector...
⚙️ Proxy server listening on localhost:6277
🔑 Session token: 3f9c…
   Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth

🚀 MCP Inspector is up and running at:
   http://localhost:6274/?MCP_PROXY_PORT=6277&MCP_PROXY_AUTH_TOKEN=3f9c…

Open that URL — the token is in it, so a bare localhost:6274 will not authenticate — and click Connect. Under ResourcesList Resources, chart://latest.png reads back as a rendered picture rather than as base64, which makes it the fastest way to answer “is the chart actually what I think it is?”. Run make bump VALUE=90 in a terminal and click the resource again to watch the revision move. Resource Templates lists chart://window/{count} with a field for count, and Tools runs get_chart and shows the image the same way.

make inspect wraps that command. Two things to know:

  • The ports are 6274 (UI) and 6277 (proxy), and a second Inspector fails with ❌ Proxy Server PORT IS IN USE at port 6277 ❌. CLIENT_PORT and SERVER_PORT move them: CLIENT_PORT=6284 SERVER_PORT=6287 make inspect.
  • uvx --from caches its build (Step 7), so an Inspector session started before an edit keeps serving the old code. make rebuild first.

There is a --cli mode as well, which prints JSON and exits — useful in a script, and the fastest way to see exactly what a client sees. make resources is this one:

npx @modelcontextprotocol/inspector --cli \
  uvx --from "$(pwd)" mcp-chart-server --method resources/list
{
  "resources": [
    {
      "name": "Latest chart (PNG)",
      "uri": "chart://latest.png",
      "description": "Bar chart of the most recent readings, labelled with the current revision.",
      "mimeType": "image/png",
      "_meta": { "fastmcp": { "tags": ["chart", "image"] } }
    },
    
  ]
}

That is the name=, description=, and mime_type= from Step 6 as they reach a client — worth a look once, because those three strings are all a client has to decide what to show a person and whether the bytes are attachable.

Other methods take the same shape. resources/read returns the base64 blob, tools/call the image content, and both can be piped into a digest check:

npx @modelcontextprotocol/inspector --cli uvx --from "$(pwd)" mcp-chart-server \
  --method resources/read --uri chart://latest.png \
  | python3 -c 'import sys,json,base64,hashlib; b=json.load(sys.stdin)["contents"][0]["blob"]; r=base64.b64decode(b); print(len(r),"bytes",hashlib.sha256(r).hexdigest()[:16])'
465 bytes 1786bcf09b2b62e0

Swapping --method tools/call --tool-name get_chart (and ["content"][0]["data"] for the blob field) prints the same 465 bytes and the same digest, which is the Step 6 tool fallback checked against the resource from outside the project’s own test suite.

Step 10: Change the chart from your shell

The smoke test changes the chart through a tool call. Because the state is a file, anything can change it — and a change from outside is the more convincing demo, since it rules out the client having cached a tool result.

Create the file

touch scripts/chartctl.py

Add the code: scripts/chartctl.py

"""Change the chart from your shell, without an MCP client in the loop.

Because the state lives in a file, a `bump` here and an `add_reading` tool call
from a model are the same operation. That is what makes the demo convincing: ask
a client to read the image, change it from another terminal, ask again.

    uv run python scripts/chartctl.py bump 42
    uv run python scripts/chartctl.py show
    uv run python scripts/chartctl.py export out/chart.png
    uv run python scripts/chartctl.py reset
"""

from __future__ import annotations

import sys
from pathlib import Path

from mcp_chart_server.chart import render_chart
from mcp_chart_server.state import ChartState, state_path

USAGE = "usage: chartctl.py {bump <0-100> | reset | show | export <path>}"


def main(argv: list[str]) -> int:
    if not argv:
        print(USAGE, file=sys.stderr)
        return 2
    command, args = argv[0], argv[1:]

    if command == "bump":
        if len(args) != 1 or not args[0].lstrip("-").isdigit():
            print(USAGE, file=sys.stderr)
            return 2
        state = ChartState.load()
        try:
            state.append(int(args[0]))
        except ValueError as exc:
            print(f"error: {exc}", file=sys.stderr)
            return 1
        state.save()
    elif command == "reset":
        state = ChartState.load().reset()
        state.save()
    elif command == "show":
        state = ChartState.load()
    elif command == "export":
        if len(args) != 1:
            print(USAGE, file=sys.stderr)
            return 2
        state = ChartState.load()
        target = Path(args[0])
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_bytes(render_chart(state.readings, state.revision))
        print(f"wrote {target}")
    else:
        print(USAGE, file=sys.stderr)
        return 2

    print(f"state file : {state_path()}")
    print(f"revision   : {state.revision}")
    print(f"readings   : {state.readings}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))

Detailed breakdown

  • It imports the package, not the server. No FastMCP instance is built and no MCP machinery runs, which keeps the “outside change” genuinely outside.
  • 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.

What opencode does not do is show it to you

Read the transcript above again for what is missing. The model got a picture; you got two lines of ASCII. opencode’s own record of the read is the string [Binary MCP resource attached: chart://latest.png (image/png)] — a description of an image, in a terminal that has no image in it. Every digest in this section was recovered by decoding a stored data URL after the fact, not by looking at anything on screen.

That is not a criticism of opencode, which is a terminal program doing the sensible thing with 465 bytes of PNG. It is the point of the table at the top of this article: loaded and displayed are two different capabilities, and this client has the first and not the second. Claude Desktop lands in the same place by a different route (Step 14). If you want to see the chart while working in opencode, keep a second terminal on make open — the exported PNG in Preview updates when you re-export, and it costs nothing.

Whether the model can see it is a separate question

opencode delivers the attachment; the model still has to be able to read images, and the provider has to forward them. Running against a local Ollama model through opencode’s OpenAI-compatible provider, the attachment arrives and the model cannot use it:

⚙ read_mcp_resource MCP resource: chart://latest.png
The system is unable to process or "read" the attached image resource
`chart://latest.png` because it does not support interpreting images.

That is not the model lacking vision. Posting the same 465 bytes directly to Ollama’s /v1/chat/completions as an image_url content part, with the same model, gets a description back:

1. The number printed at the top left: The number visible and emphasized
   in the header is 5.
2. How many bars are there: There are 3 bars shown.

It read REV 5 off the picture correctly and miscounted the bars, which is a small vision model being a small vision model. The point is that the image is legible and the failure above is in the delivery path, not the resource. So: three separate things can break a picture on its way to a model, and they fail differently.

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
Client renderingThe model can describe the image; the transcript shows none

The first three rows are things you can fix. The last row is not a failure at all from the client’s point of view, and it is where both chat clients in this article sit — opencode above, Claude Desktop in Step 14. A client can hand the model a correct image and draw nothing for the person reading, and the only way to tell that apart from a genuine drop is to ask the model something the pixels answer and your text does not. When you need to confirm the picture itself is fine, use the Inspector from Step 9 — it renders what it reads, with no model anywhere in the path.

Step 13: Wrap it in a Makefile

Create the file

touch Makefile

Add the code: Makefile

# updating-image-mcp-resource-macos/Makefile
.DEFAULT_GOAL := help

SERVER  := chart
VALUE   ?= 42

.PHONY: help
help: ## Show this help screen
	@echo "An updating image as an MCP resource - available targets:"
	@echo ""
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| awk 'BEGIN {FS = ":.*?## "} {printf "  \033[36m%-14s\033[0m %s\n", $$1, $$2}'
	@echo ""
	@echo "Launch command: uvx --from $(CURDIR) mcp-chart-server"

.PHONY: install
install: ## Sync runtime and dev dependencies
	uv sync

.PHONY: test
test: ## Run the pytest suite
	uv run pytest -q

.PHONY: check
check: ## Render the chart through the packaged entry point (no server)
	uvx --from $(CURDIR) mcp-chart-server --check

.PHONY: smoke
smoke: ## Drive the server over stdio and prove the image changes
	uv run --script scripts/smoke_test.py $(CURDIR)

.PHONY: inspect
inspect: ## Open the MCP Inspector against this project (needs Node)
	npx @modelcontextprotocol/inspector uvx --from $(CURDIR) mcp-chart-server

.PHONY: resources
resources: ## List the server's resources through the Inspector CLI
	npx @modelcontextprotocol/inspector --cli \
		uvx --from $(CURDIR) mcp-chart-server --method resources/list

.PHONY: serve
serve: ## Run the server over stdio from the working tree
	uv run mcp-chart-server

.PHONY: bump
bump: ## Add a reading from the shell (make bump VALUE=70)
	uv run python scripts/chartctl.py bump $(VALUE)

.PHONY: reset
reset: ## Clear the chart and set the revision to zero
	uv run python scripts/chartctl.py reset

.PHONY: show
show: ## Print the current revision and readings
	uv run python scripts/chartctl.py show

.PHONY: open
open: ## Export the current chart to out/chart.png and open it in Preview
	uv run python scripts/chartctl.py export out/chart.png
	open out/chart.png

.PHONY: rebuild
rebuild: ## Invalidate the uvx build cache after editing the server
	touch pyproject.toml
	uvx --from $(CURDIR) mcp-chart-server --check

.PHONY: config
config: ## Render opencode.json for this checkout (project scope)
	@sed 's#/absolute/path/to/updating-image-mcp-resource-macos#$(CURDIR)#' \
		opencode.json.example > opencode.json
	@echo "wrote opencode.json for $(CURDIR)"

.PHONY: desktop-config
desktop-config: ## Print the Claude Desktop entry to paste into its config
	@printf '  "mcpServers": {\n    "chart": {\n      "command": "%s",\n      "args": ["--from", "%s", "mcp-chart-server"]\n    }\n  }\n' \
		"$$(command -v uvx)" "$(CURDIR)"

.PHONY: desktop-logs
desktop-logs: ## Tail Claude Desktop's log for this server (^C to stop)
	tail -f "$$HOME/Library/Logs/Claude/mcp-server-$(SERVER).log"

.PHONY: add
add: ## Register the server with opencode (global config)
	opencode mcp add $(SERVER) -- uvx --from $(CURDIR) mcp-chart-server

.PHONY: list
list: ## List configured MCP servers and their connection status
	opencode mcp list

.PHONY: clean
clean: ## Remove caches and exported images
	rm -rf .pytest_cache out dist **/__pycache__

Detailed breakdown

  • .DEFAULT_GOAL := 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.
  • inspect and resources shell out to npx, so they need Node where every other target needs only uv. Both pass $(CURDIR), which is why they work from any directory while the raw commands in Step 9 use $(pwd) and assume you are standing in the project.
  • desktop-logs hard-codes the macOS log path and interpolates $(SERVER), so the target keeps matching if the server is renamed — Claude Desktop names the file after the mcpServers key, not after the package.
  • $$ 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
  inspect        Open the MCP Inspector against this project (needs Node)
  resources      List the server's resources through the Inspector CLI
  serve          Run the server over stdio from the working tree
  bump           Add a reading from the shell (make bump VALUE=70)
  reset          Clear the chart and set the revision to zero
  show           Print the current revision and readings
  open           Export the current chart to out/chart.png and open it in Preview
  rebuild        Invalidate the uvx build cache after editing the server
  config         Render opencode.json for this checkout (project scope)
  desktop-config Print the Claude Desktop entry to paste into its config
  desktop-logs   Tail Claude Desktop's log for this server (^C to stop)
  add            Register the server with opencode (global config)
  list           List configured MCP servers and their connection status
  clean          Remove caches and exported images

Launch command: uvx --from /Users/you/projects/updating-image-mcp-resource-macos mcp-chart-server

Step 14: Claude Desktop, and what a GUI client will not do

opencode proves the resource is delivered and lets a model act on it. Claude Desktop is the obvious next place to try, since a chat window that renders images should be where “did the chart update?” becomes a question you answer with your eyes.

It is also where this article’s server runs into the most instructive wall in it. Wiring up takes one config file; getting the picture on screen is a different matter, and the measurements below are the reason Step 6 has a tool fallback at all.

Print the entry and paste it into ~/Library/Application Support/Claude/claude_desktop_config.json:

make desktop-config
  "mcpServers": {
    "chart": {
      "command": "/opt/homebrew/bin/uvx",
      "args": ["--from", "/Users/you/projects/updating-image-mcp-resource-macos", "mcp-chart-server"]
    }
  }

The absolute uvx path is the part people get wrong. Claude Desktop is a GUI application launched by Finder, so it does not inherit your shell’s PATH and a bare "uvx" fails with a launch error that says nothing useful. command -v uvx resolves it, which is why make desktop-config shells out rather than hard-coding /opt/homebrew/bin/uvx.

Quit Claude Desktop completely (⌘Q, not just closing the window) and reopen it — it reads the config only at launch. Then attach the resource from the controls row at the bottom of the message composer: the + button to the left of the input box, then an entry named after the server (chart), then Latest chart (PNG). Some builds put MCP attachments behind a connectors or plug icon in that same row instead. That label is the name= argument from Step 6, which is the one place resource metadata is shown to a person rather than to a model, and the reason it is worth writing something better than the function name.

That is the flow as documented, and it is the one part of this article that was never made to happen. On 1.24012.9 the resource entry was not located in the composer, and the server-side counter below is the reason to believe that is a client limit rather than a missing click: across every session, resources/read stayed at 0. Nothing in this step should be read as “attach the chart, bump it, attach it again, and watch two revisions appear side by side.” That is what the UI is for; it is not what happened here.

Read the Claude Desktop MCP logs

Claude Desktop writes one log per MCP server, plus a combined one, to ~/Library/Logs/Claude/. The per-server file is named after the key in mcpServers, so this server’s is mcp-server-chart.log:

ls ~/Library/Logs/Claude/
main.log                mcp-server-chart.log    mcp.log
  • mcp-server-<name>.log is the one to read: the launch command, the server’s own stderr, and every JSON-RPC message in both directions.
  • mcp.log is the combined connection-level log across all servers. It grows fast — several megabytes in a few sessions — so reach for the per-server file first.

Watch it live while Claude Desktop starts (make desktop-logs is this command):

tail -f ~/Library/Logs/Claude/mcp-server-chart.log

A successful launch opens with the resolved command and the PATH it searched:

[chart] [info] Initializing server...
[chart] [info] Using MCP server command: /opt/homebrew/bin/uvx with args and path: {
  metadata: {
    args: [ '--from', '/Users/you/projects/updating-image-mcp-resource-macos',
            'mcp-chart-server', [length]: 3 ],
    paths: [ '/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin', … ]
  }
}
[chart] [info] Server started and connected successfully

That paths array is the answer to the PATH problem above: it is the entire search path Claude Desktop has, and it is not your shell’s. If a bare "uvx" in the config fails, this is where you see why.

Then the handshake, which is where the resource question gets settled:

[chart] [info] Message from client: {"method":"initialize",…,"clientInfo":{"name":"claude-ai","version":"0.1.0"}}
[chart] [info] Message from client: {"method":"tools/list","params":{},"jsonrpc":"2.0","id":1}
[chart] [info] Message from client: {"method":"prompts/list","params":{},"jsonrpc":"2.0","id":2}
[chart] [info] Message from client: {"method":"resources/list","jsonrpc":"2.0","id":3}
[chart] [info] Message from server: {"jsonrpc":"2.0","id":3,"result":{"resources":[
  {"name":"Latest chart (PNG)","uri":"chart://latest.png",…,"mimeType":"image/png",…},
  {"name":"Latest chart (SVG)","uri":"chart://latest.svg",…,"mimeType":"image/svg+xml",…},
  {"name":"Chart state","uri":"chart://state",…,"mimeType":"application/json",…}]}}

Four calls at startup, and then nothing. Count the interesting methods across a whole session:

for m in resources/list resources/templates/list resources/read tools/call; do
  printf '%-26s %s\n' "$m" "$(grep -c "$m" ~/Library/Logs/Claude/mcp-server-chart.log)"
done
resources/list             1
resources/templates/list   0
resources/read             0
tools/call                 0

Two facts fall out of those zeros, and both are worth knowing before you design a server around resources:

  • resources/read was never called. Not once, across a session spent asking the model to read chart://latest.png. The resource is listed, the metadata is correct, the bytes are ready — and no one asks for them. This is the evidence behind the next section: in Claude Desktop the read happens when a person attaches the resource, and a prompt cannot trigger it.
  • resources/templates/list was never called either. Claude Desktop asks for static resources only, so chart://window/{count} does not exist as far as that client is concerned. The MIME-type trap from Step 6 is real, but it is opencode and the Inspector that can reach the template at all.

The same grep is the first thing to run on any “my server is connected but nothing happens” report, whatever the server does. It separates the client never asked from the server answered badly, and those have completely different fixes.

When you cannot find the resource at all

Two limits meet here, and together they are the reason Step 6 has get_chart.

The attachment is a GUI flow with no documented location. It is a menu item whose position has shifted across Claude Desktop releases, and no config key or CLI reaches it. On 1.24012.9 the server connects and resources/list answers — make smoke and make inspect prove the server half outright — while the resource entry stays hard to find in the UI. The log tells you which half is at fault, but it cannot tell you where the menu item went.

A prompt cannot reach a resource in Claude Desktop. Asking the model to “read chart://latest.png and tell me the revision” does not work, and the zero resources/read calls in the log above are what that failure looks like from the server’s side: the request is never made. Resources go into a conversation when a person attaches them. opencode’s read_mcp_resource (Step 12) is the contrast — there, the same URI is one model-initiated call away.

Claude Code is the sharper contrast, because it is the same vendor. Measured on Claude Code 2.1.220 against a separate probe server (projects/python/mcp-image-display-clients-macos), a prompt naming a resource URI produced resources/read 1 with tools/call 0, and the model answered a question only the image could answer. Same company, same month, one client that reaches resources from a prompt and one that cannot. Whatever you conclude about resource reachability, scope it to a client and a version — a vendor is not the unit that decides this.

So a prompt that is supposed to report the revision goes through the tools instead:

Call get_chart and get_chart_state, show me the chart, and tell me the revision.

Tools are listed in every client and callable by the model without a human in the loop, so this path does not depend on finding anything. Run against Claude Desktop 1.24012.9 it half works, and the two halves are worth separating.

The data arrives. The log records three tools/call requests, each answered result(1 blocks) with no error, and the model reported the revision and the readings correctly. For anything a text resource would have carried, get_chart_state is a complete substitute for chart://state.

The picture never shows up on screen. get_chart returns ImageContent with image/png — confirmed through the Inspector against the exact command in the config — and the server logged a one-block result for that call too. What lands in the conversation is a collapsed Get chart block. Expanding it shows no image. Asking “can I see the chart?” produces another tool call, another block, and a sentence describing the chart that the model could have written from get_chart_state alone.

The obvious conclusion is that the image was dropped somewhere. It was not, and the way to find out costs one question. Nothing this server sends as text names a colour: get_chart_state returns a revision, a list of readings, a byte count, a digest, and a file path, and the tool and resource descriptions say “bar chart” and stop there. So ask something only the pixels can answer.

What colour is the bar?
Orange.

Which is correct, and not the guess a model would make about a bar chart. A lone reading is also the newest one, so it is drawn in LATEST(230, 120, 20) — rather than the (48, 110, 200) blue every other bar gets. The block reached the model intact. It is the transcript that shows nothing.

That puts the gap in a more awkward place than “the image was rejected”. On this client an image resource is unreachable from a prompt, an image tool is reachable and the model can act on what comes back, and neither route puts a picture in front of the person reading along. Three parties, and only two of them see it.

Which is fine, as long as you know which audience you are serving. If the model is the consumer — deciding, summarising, comparing against earlier readings — get_chart is sufficient here and needs nothing from the UI. If you are the consumer, use the Inspector (Step 9) or opencode (Step 12), where the picture is rendered rather than described. And resources remain the right shape for this data: on a client that gives the model access to them they are the better path, addressable and cacheable and free when unused. The tools are there so the server does not depend on that.

Everything before this step is covered by make smoke and make test, so if the attachment does not appear, the problem is the client or the config file, not the server.

Why a chat client is a poor display path

Pull the measurements together and one design conclusion falls out. The MCP specification says a resource carries a MIME type and a body. It does not say the client must render either one. Everything about whether a picture appears on a screen lives above the protocol, in UI code that is undocumented, unversioned in any way you can query, and free to move between releases — as Claude Desktop’s attachment menu did.

So a server whose payoff is “the user sees the image in the chat” is betting on a feature nobody promised. Three separate client decisions have to go your way:

  1. Attach or drop the bytes. opencode checks the MIME type against five values and the size against 10 MiB. Miss either and the image is replaced by a sentence saying it was omitted.
  2. Give the model a way to ask. opencode ships read_mcp_resource; Claude Desktop 1.24012.9 does not, which makes a resource there reachable only by a human clicking a menu.
  3. Draw it. Neither chat client does, for either route.

A fourth decision waits behind those for anyone serving an image that changes. Re-reading a URI is also the client’s call: some cache a resource for the session, some fetch once and never again, some re-fetch only when a person reopens it. This server takes that off the table by making the state a file and the revision visible in the picture, so a stale read is at least detectable — but detecting it is all a server can do.

What to do instead, in the order I would reach for it:

  • Aim the image at the model, not the screen. A tool returning fastmcp.utilities.types.Image reaches every client, is model-callable without a human in the loop, and is verified here to arrive intact. That is the one path that worked in all three clients.
  • Ship a text resource next to the image. chart://state costs a few hundred bytes and answers most of what anyone wanted the picture for, on models with no vision at all.
  • Point a human at a real viewer. The MCP Inspector (Step 9) renders what it reads. make open writes the PNG and opens Preview. Both beat any chat window measured here, and neither involves a model.
  • If a person really must see it from a conversation, serve a link, not bytes. A URL to something a browser can open sidesteps every rule above. Serve Media from a FastMCP Server on macOS covers the expiring-link pattern.

What about ChatGPT?

Untested here, and the honest answer is that nobody should guess. The tempting argument is that ChatGPT already renders uploaded images and images it generates, so an MCP image would just flow into the same pipeline. Claude Desktop is the counterexample that kills that reasoning: it renders uploaded images and images it generates too, and it still shows nothing for an ImageContent block returned by a tool. An existing image pipeline says nothing about whether MCP content is routed into it.

Treat it as a measurement waiting to be taken, and take it the same way this article took the others:

  1. Count the methods the client actually calls on the server side — resources/list, resources/read, tools/call. That separates “the client never asked” from “the server answered badly”, and it is the check that settled Claude Desktop.
  2. Ask the model something only the pixels answer. What colour is the bar? costs one question and tells you whether the block reached the model, which is a different question from whether it reached your eyes.

Until that run exists, the table at the top of this article is the whole of what is known: one client draws the picture, and it is not a chat client.

Troubleshooting

✗ chart failed with MCP error -32000: Connection closed. The process died before the handshake. Run the launch command by hand — uvx --from <path> mcp-chart-server --check — where the real error goes to your terminal instead of being collapsed into one status line.

You fixed the server and nothing changed. The uvx build cache. make rebuild, or touch pyproject.toml. See the table in Step 7: --refresh, --refresh-package, and uv cache clean <pkg> do not help.

[Binary MCP resource omitted: … is not a supported attachment type]. The MIME type is not one of opencode’s five (application/pdf, image/gif, image/jpeg, image/png, image/webp). If you are reading a template, the likely cause is the FastMCP behaviour from Step 6: return ResourceResult([ResourceContent(png, mime_type="image/png")]), not bare bytes.

[Binary MCP resource omitted: … exceeds 10 MB]. The cap is 10485760 bytes — 10 MiB, which opencode’s message rounds off as 10 MB — measured on the decoded bytes, not on the base64 length. Downscale, or serve a link and let the client fetch it — Serve Media from a FastMCP Server on macOS covers the expiring-link pattern for payloads too big to inline.

The model reports it cannot interpret images. The resource arrived; the model or the provider path cannot use it. Check the stored tool result for attachments: 1, then try a vision-capable model on a first-class provider, or Step 14.

The image never appears in the chat window. Expected, in both chat clients measured here — see the table at the top and Step 14. Before changing anything, establish which half is missing: check the server log for a tools/call or resources/read that was answered without an error, then ask the model “what colour is the bar?”. A correct answer means the bytes reached the model and only the UI omitted them, which is not a bug you can fix from the server. Use the Inspector or make open when you need to look at the picture yourself.

The client connects but the model never sees the resource. Read ~/Library/Logs/Claude/mcp-server-chart.log (or make desktop-logs) and grep for resources/read. No matches means the client never asked, which is a client problem, not a server one: either it has no resource-reading tool — Claude Desktop 1.24012.9, per Step 14 — or the model was never told the resource exists. For the first, call get_chart and get_chart_state; for the second, name the server and the URI in the prompt. Try the second before concluding the first: naming the URI outright is what produced a resources/read on Claude Code 2.1.220, and the two failures look identical from the server until you have ruled it out.

❌ Proxy Server PORT IS IN USE at port 6277 ❌. An Inspector is already running. Close the other one, or move both ports: CLIENT_PORT=6284 SERVER_PORT=6287 make inspect.

The Inspector shows stale behaviour after an edit. Same uvx build cache as everything else — the Inspector spawns the server through the same command. make rebuild, then reconnect.

The revision never changes. The client and your shell are reading different state files. Every chartctl.py command prints the path it used, and chart://state reports state_file; compare the two. A CHART_STATE set in one environment and not the other is the usual cause.

The chart is empty after a client restart. It should not be — state is a file. If it is, the server is writing somewhere unexpected, which again means checking state_file in chart://state.

Notes on serving images as resources

  • Set mime_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.
  • Check the client’s log before you change the server. A per-server log that shows resources/list answered and resources/read never called is telling you the server is fine and the client never asked. Without that check the obvious move is to start editing MIME types and return values, which fixes nothing.
  • Mirror the important resources as tools. Resource access is client-initiated and unevenly implemented; tool calls are model-initiated and reach every client. A two-line tool that returns Image over the same renderer costs nothing. Keep both routes on one implementation so they cannot drift, and test that they produce identical bytes.
  • Delivering an image is not the same as displaying one. A client can call your tool, pass a correct ImageContent block to the model, and still show the person reading nothing at all — Step 14 measures exactly that, and separates the two by asking the model a question only the pixels can answer. Decide which audience you are serving before you conclude anything is broken.
  • Do not design around a chat client displaying it. Of the three clients measured here, only the MCP Inspector draws the picture. Rendering is a UI decision the specification does not require, and it has already moved between Claude Desktop releases. Build for the model, and send a human to a viewer or a link.
  • Keep a question the picture alone can answer. A colour, a shape, a mark that appears in no field of your JSON. It costs nothing and it is the only way to tell “the model never got the image” from “the client never drew it”, which have entirely different fixes.
  • Subscriptions are still not first-class. FastMCP 3.4.4 advertises resources.subscribe=false, and opencode re-reads on demand rather than subscribing, so there is nothing to push to. Serve Resources Well from an MCP Server on macOS covers the low-level handlers for clients that do subscribe.

Recap

The server serves a real image: a bar chart encoded by 40 lines of zlib and struct, rendered on every read from state that a tool call or a shell command can change, delivered as a base64 blob with image/png on it. uvx --from <dir> runs it straight from a local directory with no publishing step, and opencode launches it, reads it, and hands the model an attachment that is byte-identical to what the renderer produced.

Three things along that path are easy to get wrong and hard to diagnose, and each one is pinned by a test or a measured table above: FastMCP drops a template’s MIME type unless you wrap the bytes, opencode silently omits any binary resource whose MIME type is outside its five, and uvx will happily run yesterday’s build until pyproject.toml is touched.

A fourth is not a bug in anything: whether a model can reach a resource at all is the client’s decision, and clients disagree. get_chart and get_chart_state mirror the two resources that matter as tools, over the same renderer and pinned to the same bytes by a test, so the server is useful even where resources are a manual attachment.

And the finding that should shape what you build next: of the three clients measured, only the MCP Inspector puts the image on screen. opencode and Claude Desktop both hand it to the model and show you a line of text. That is not a protocol problem and it is not fixable from the server, so the picture is for the model, and you should look at it in the Inspector or in Preview.

Next improvements:

  • Run the two measurements above against a ChatGPT MCP connector and add a fourth row to the table — method counts on the server side, then the colour question. It is the one client people ask about that this article cannot answer.
  • Rasterize the SVG server-side and serve both, so clients that render markup and clients that only take blobs are both satisfied.
  • Add a chart://compare/{a}/{b} template that renders two revisions side by side, turning “did it change?” into a single read.
  • Back the readings with a real source — CPU load, a queue depth, a build timer — and let the picture be worth looking at.
  • Publish the package to PyPI so the launch command drops the --from and the cache problem with it. Publish a FastMCP Server to PyPI and Run It Anywhere with uvx covers that path.