display-image-mcp
Allows ESPHome e-paper displays to fetch rendered images over HTTP using the online_image component, enabling display updates without reflashing.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@display-image-mcp render a 296×128 card on my e-paper showing today's calendar"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
display-image-mcp
An MCP server that renders images for small displays (e-paper, OLED, TFT) and hosts them over HTTP.
It deliberately does one thing: turn a description of a screen into a pixel-exact image for a given display, and serve it. It knows nothing about where the image is shown or when to refresh it, so any agent, automation or device can use it. Whatever drives your display (Home Assistant, Node-RED, an LLM agent) decides when to push, and the display fetches the URL.
Concepts
Profile: a kind of display: resolution, colour depth and output format.
mono,gray4,gray16,tricolor_red,tricolor_yelloworrgb. Output is BMP or PNG. Drawing colours snap to what the profile can show.Label: a name for an image within a profile. Rendering to an existing label overwrites it, so a display can keep polling one stable URL. A profile can hold many labels (e.g.
main,alert,calendar).Digest:
GET /images/<profile>lists what is available, with sha256, size and last-changed time.
Built-in profiles (see list_profiles): waveshare-2in9-v2 (296×128 mono), waveshare-2in9-b (tricolor), waveshare-2in13-v3, waveshare-4in2, waveshare-7in5-v2, ssd1306-128x64, st7789-240x240 (RGB). Add your own with a TOML file:
# profiles.toml (pass with --profiles or DISPLAY_MCP_PROFILES)
[profiles.kitchen-lcd]
width = 320
height = 240
color_mode = "rgb" # mono | gray4 | gray16 | tricolor_red | tricolor_yellow | rgb
format = "png" # bmp | png
description = "Kitchen 320x240 TFT"Profile and label names are lowercase letters, digits, - and _.
Related MCP server: hyperweave
MCP tools
Tool | What it does |
| Display profiles available |
| Preferred. Nested |
| Quick header/lines/footer layout, sized to the display |
| Exact pixel placement from |
| Stored images with URL, sha256 and last-changed time |
| Remove an image |
Render tools return the image url, sha256 and changed, which is false when the pixels are identical to what was already stored. A caller can use that to skip a needless e-paper refresh.
Layouts
render_layout takes a tree. Containers split the space between their children, so you say how things relate (a title row, a big number filling the left, a list on the right) and the server does the measuring. A 296×128 display, for example:
{"type": "column", "padding": 6, "gap": 4, "children": [
{"type": "row", "h": 22, "children": [
{"type": "text", "text": "TUESDAY", "size": 16, "bold": true, "flex": 1},
{"type": "text", "text": "14:05", "size": 16, "align": "right"}]},
{"type": "rect", "h": 2, "fill": "black"},
{"type": "row", "flex": 1, "gap": 8, "children": [
{"type": "text", "text": "21°", "size": "fit", "bold": true, "flex": 1, "align": "center", "valign": "middle"},
{"type": "column", "flex": 2, "gap": 3, "children": [
{"type": "text", "text": "Standup in 12 min", "size": 14},
{"type": "text", "text": "Front door open for 20 minutes", "size": 12, "max_lines": 2},
{"type": "progress", "h": 8, "value": 0.6}]}]},
{"type": "text", "text": "updated 14:05", "size": 10}]}Node | Properties |
every node |
|
|
|
|
|
|
|
|
|
| empty space; give it |
size: "fit" picks the largest font size that fits the text's box, so give it flex or w/h. Text longer than its box is cut with an ellipsis; raise max_lines to wrap instead.
Warnings. The result always includes warnings, such as truncated text, children that need more room than the container has, or elements that got no space or fall off the canvas. They name the offending node (root.children[1].children[0]), so a caller can correct the layout and render again to the same label. Layouts are limited to 8 levels and 120 nodes.
Ready-made layouts are in examples/ (dashboard, alert, agenda) and are covered by the tests. They are written for a 296×128 display; the same trees adapt to other sizes because sizes are relative.
HTTP
Endpoint | |
| The image ( |
| Digest for one profile |
| Digest for every profile that has images |
| Profile definitions |
The image host has no authentication, because displays like an ESP32 can't easily send headers. Run it on a trusted network and be careful what you render. The MCP endpoint can be protected with a bearer token (see below).
Run
uv sync
uv run display-image-mcp # MCP over stdio + image host on :8099
uv run display-image-mcp --transport streamable-http # MCP at http://127.0.0.1:8767/mcpOption | Env | Default |
|
|
|
|
| none |
|
|
|
|
|
|
|
| Pillow's built-in vector font |
|
|
|
|
| none. If set, the MCP endpoint requires |
|
|
|
Run as a standalone service
The server is meant to run on its own, independent of whichever agent uses it: the agent just connects to a URL and never starts or stops the process. Use the streamable-http transport and let your OS service manager own the lifecycle.
Install it as a uv tool (no checkout needed):
uv tool install git+https://github.com/frizzy/display-image-mcp # puts display-image-mcp in ~/.local/bin
uv tool upgrade display-image-mcp # later, to updateCheck it by hand first (replace the IP with this machine's LAN address):
display-image-mcp --transport streamable-http --mcp-host 0.0.0.0 \
--data-dir ~/display-images --base-url http://192.168.1.10:8099MCP endpoint:
http://192.168.1.10:8767/mcpImage host:
http://192.168.1.10:8099
--mcp-host 0.0.0.0 lets an agent on another machine connect. Protect the MCP endpoint with a token: generate one with openssl rand -hex 32 and pass it as --token or DISPLAY_MCP_TOKEN; clients then send Authorization: Bearer <token>. Without a token the server prints a warning when bound to a non-local address. Keep both ports on a trusted network either way, or leave --mcp-host at its 127.0.0.1 default if the agent runs on the same machine.
Linux (systemd)
/etc/systemd/system/display-image-mcp.service. Adjust User and the paths. uv tool installs into that user's ~/.local/bin:
[Unit]
Description=display-image-mcp
After=network-online.target
Wants=network-online.target
[Service]
User=pi
ExecStart=/home/pi/.local/bin/display-image-mcp --transport streamable-http --mcp-host 0.0.0.0
Environment=DISPLAY_MCP_DATA=/home/pi/display-images
Environment=DISPLAY_MCP_BASE_URL=http://192.168.1.10:8099
Environment=DISPLAY_MCP_TOKEN=put-a-long-random-token-here
# Environment=DISPLAY_MCP_PROFILES=/home/pi/display-profiles.toml
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now display-image-mcp
journalctl -u display-image-mcp -fmacOS (launchd)
~/Library/LaunchAgents/com.example.display-image-mcp.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>com.example.display-image-mcp</string>
<key>ProgramArguments</key><array>
<string>/Users/you/.local/bin/display-image-mcp</string>
<string>--transport</string><string>streamable-http</string>
<string>--mcp-host</string><string>0.0.0.0</string>
</array>
<key>EnvironmentVariables</key><dict>
<key>DISPLAY_MCP_DATA</key><string>/Users/you/display-images</string>
<key>DISPLAY_MCP_BASE_URL</key><string>http://192.168.1.10:8099</string>
<key>DISPLAY_MCP_TOKEN</key><string>put-a-long-random-token-here</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardErrorPath</key><string>/tmp/display-image-mcp.log</string>
</dict></plist>launchctl load ~/Library/LaunchAgents/com.example.display-image-mcp.plistThe unit files above follow standard systemd and launchd usage. I have not run them myself: only the uv tool install command and the LAN-bound streamable-http endpoint were tested.
Running with Docker
A multi-arch image (amd64 and arm64, so a Raspberry Pi works too) is published to GitHub Container Registry as ghcr.io/frizzy/display-image-mcp, built by CI from main (:latest) and from v* tags (:X.Y.Z, :X.Y). To build it yourself, run docker build -t display-image-mcp . in a checkout and use that name instead.
Always on, with Docker Compose
docker-compose.yml runs the streamable-HTTP server with a restart policy, a health check, a read-only filesystem, no capabilities and a volume for the images:
DISPLAY_MCP_BASE_URL=http://192.168.1.10:8099 docker compose up -d # or put that line in a .env file
docker compose logs -f
docker compose pull && docker compose up -d # updatePort | What | Published to |
| Image host. Displays fetch from here | your network |
| MCP endpoint, | this machine only |
The MCP endpoint is bound to localhost by default. To let an agent on another machine connect, set DISPLAY_MCP_TOKEN (in .env or the environment) and change the mapping to "8767:8767". To add your own profiles, mount a TOML file and set DISPLAY_MCP_PROFILES (there is a commented example in the compose file).
Plain docker run
docker run -d --name display-image-mcp --restart unless-stopped \
-p 8099:8099 -p 127.0.0.1:8767:8767 -v display-images:/data \
-e DISPLAY_MCP_BASE_URL=http://192.168.1.10:8099 -e DISPLAY_MCP_TOKEN=<token> \
ghcr.io/frizzy/display-image-mcp --transport streamable-http --mcp-host 0.0.0.0Without --transport the container speaks MCP over stdio (docker run -i --rm ...), but then whatever launches it owns its lifecycle.
Connecting an agent
Point any MCP client that supports streamable HTTP at the URL, e.g. http://192.168.1.10:8767/mcp, with the header Authorization: Bearer <token> if you set a token. Check your client's docs for how it takes a remote server URL and headers. If a client can only launch local stdio servers, you can still use display-image-mcp (no flags) as its command, but then that client owns the process lifecycle, which is what this setup avoids.
Example: ESPHome e-paper
The display side just downloads a URL:
online_image:
- platform: online_image
id: dashboard_image
url: "http://192.168.1.10:8099/images/waveshare-2in9-v2/main.bmp"
format: BMP
type: BINARY
on_download_finished:
- component.update: eink_displayTo change the source without reflashing, expose an API action that calls online_image.set_url with a URL argument.
Development
uv run pytestLicense
MIT
Available Tools
6 toolsdelete_imageC
Delete the image stored for (profile, label).
Inputs: label (required), profile (required).
list_imagesA
List stored images (all profiles, or one) with URL, sha256, size and last-changed time.
Inputs: profile. Returns: result
list_profilesA
List display profiles (resolution, colour mode, output format).
Inputs: no named parameters. Returns: result
render_cardA
Render a simple card (header bar, body lines, footer) and store it as (profile, label).
Text that is too long is truncated with an ellipsis and extra lines beyond what fits are
dropped, so keep it short. Returns the image URL, its sha256 and changed (false when the
pixels are identical to what was already stored, so a caller can skip a display refresh).
Inputs: label (required), lines (required), header (required), profile (required), +3 more.
render_imageB
Render a custom layout from drawing elements and store it as (profile, label).
Origin (0,0) is top-left; sizes are in pixels. Colours are names (black, white, red, yellow,
gray, dark_gray, light_gray) or #rrggbb; they snap to what the profile can show. Elements:
{"type":"text","text":..,"x":..,"y":..,"size":12,"color":"black","bold":false,
"anchor":"la","max_width":px,"max_lines":1} (anchor: Pillow anchors, e.g. "la","mm","ra")
{"type":"rect","x":..,"y":..,"w":..,"h":..,"fill":colour|null,"outline":colour|null,"width":1}
{"type":"line","x1":..,"y1":..,"x2":..,"y2":..,"color":"black","width":1}
{"type":"progress","x":..,"y":..,"w":..,"h":..,"value":0.0-1.0}
Returns the image URL, sha256 and changed (see render_card).
Inputs: label (required), profile (required), elements (required), background, +1 more.
render_layoutA
Lay out a screen from nested rows and columns (no pixel coordinates) and store it as (profile, label).
root fills the whole display. Containers split space between children; text can fit itself.
Always check warnings in the result: it reports truncated text, overflow and elements that got
no room, so you can fix the layout and render again (same label overwrites).
Nodes (every node accepts w, h in px and flex as a share of leftover space):
{"type":"column"|"row","children":[...],"gap":4,"padding":6,"align":"stretch|start|center|end",
"justify":"start|center|end|space-between","fill":colour,"border":px,"border_color":colour}
padding is a number, [vertical, horizontal] or [top, right, bottom, left].
align = cross axis (default stretch); justify = main axis when no child has flex.
{"type":"text","text":"..","size":12|"fit","bold":false,"color":"black","align":"left|center|right",
"valign":"top|middle|bottom","max_lines":1,"min_size":8,"max_size":96,"fit":"shrink"}
size "fit" (or fit "shrink") picks the largest size that fits the box: give it flex or w/h.
Text longer than its box is cut with an ellipsis (and warned): raise max_lines to wrap.
{"type":"rect","fill":colour,"outline":colour} (a bar or rule: {"type":"rect","h":2,"fill":"black"})
{"type":"progress","value":0.0-1.0,"h":8}
{"type":"spacer"} (give it flex or w/h)
Colours: black, white, red, yellow, gray, dark_gray, light_gray or #rrggbb (snapped to the profile).
Limits: 8 levels deep, 120 nodes.
Inputs: root (required), label (required), profile (required), background, +1 more.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 6 tools
The three render tools are distinguishable: render_card is a fixed header/body/footer template, render_image takes pixel-coordinate drawing elements, and render_layout uses nested rows/columns without coordinates. They could be confused at a glance (card output is achievable via the other two), but the descriptions clarify the boundaries well. list_profiles vs list_images is also clearly separated by resource.
All six tools follow a strict verb_noun snake_case pattern: list_profiles, list_images, render_card, render_image, render_layout, delete_image. Verb families are internally consistent (render_*, list_*). No mixed conventions.
Six tools is well-scoped for a display image rendering/storage server. Each tool earns its place: profile discovery, three rendering variants, listing, and deletion. No filler or redundancy.
The image lifecycle is mostly covered: render (create), list (read), delete. However, there is no single-image get/inspect tool to fetch metadata for one (profile, label) without listing everything, and profiles can only be listed, not defined. These are minor gaps agents can work around via list_images.
Related MCP Connectors
Charts and dashboards as images for AI agents. No browser, same data same bytes. Local and free.
Render Open Graph and social images from HTML templates, build og:image URLs, AI templates.
Deterministic image rendering for agents: JSON template in, on-brand PNG out.
Display delivery platform for AI agents. Push HTML, dashboards and live data to screens.
Related MCP Servers
- AlicenseAqualityBmaintenanceCapture screenshots, generate PDFs, and render HTML to images via AI agents. Supports batch capture, geo-targeting, async webhooks, and CSS/JS injection.1169 npm6MIT
- AlicenseNot gradedqualityBmaintenanceHyperWeave gives agents a visual output surface: structured data in, branded SVG out. No JavaScript, no runtime. Works anywhere an image can be embedded. Badges, cards, charts, dashboards, diagrams, and more.59Apache 2.0
- AlicenseNot gradedqualityDmaintenanceHosted, SSRF-safe, cached screenshots and Open Graph images for AI agents - no headless Chrome to run.46 npmMIT
- AlicenseNot gradedqualityDmaintenanceEnables AI agents to create high-resolution marketing images from simple JSON configs without design skills or API keys. Provides presets, themes, and layouts to render deterministic PNGs locally.MIT