pictor-mcp
pictor-mcp is a secure MCP server that lets a model inspect, transform, batch-process and verify images server-side.
Inspect —
image_capabilities(formats, operations, limits, security posture, GPU status),image_info(dimensions, format, mode, frames, EXIF, perceptual hashes, dominant colours),image_compare(SSIM, RMSE, PSNR, changed-pixel ratio, hash distance).Convert —
image_convertbetween JPEG, PNG, WebP, AVIF, TIFF, GIF, BMP, ICO, JPEG 2000, QOI and PPM, rasterising SVG input; controls quality, losslessness, effort and metadata.Resize —
image_resizeby width, height, percentage or box, with six fit modes (contain, cover, fill, inside, outside, pad), six filters and optional format change.Compress —
image_compressby quality level or by searching for the best quality under a target size in KB.Crop —
image_cropby pixel box, aspect ratio with gravity anchor, or auto-trim of a uniform border.Rotate/flip —
image_rotateany angle, mirror horizontally/vertically, EXIF auto-orient, optional canvas expansion.Thumbnail —
image_thumbnailat an exact size with saliency-based (attention/entropy) cropping instead of blind centring.Watermark —
image_watermarkwith text or image, position, opacity, rotation, tiling, shadow and stroke.Background removal —
image_background_removevia fast colour keying or a U²-Net ML model, with feathering, tolerance and edge-connected protection.Pipeline —
image_transformruns an ordered list of up to 32 operations (auto_orient, resize, crop, rotate, flip, sharpen, blur, smart_crop, watermark_text, watermark_image, background_remove, feather) in a single call.Batch —
image_batchapplies the same pipeline across many files (directory, glob or explicit paths), with per-file error isolation, totals and an optional inline first image.Web-ready output —
image_optimize_webgenerates a width ladder of variants, a blurred placeholder and a paste-ready srcset.Flexible inputs — every tool takes exactly one of
path(confined to allowed roots),base64_data, orurl(off by default, SSRF-guarded).Rich results — text summary, structured JSON (
outputs, sizes, SHA-256, dimensions, optional signed URL), optional inline base64 image and resource link; errors carry stable machine-readable codes.
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., "@pictor-mcp Resize all images in input to 800px wide and save them to output"
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.
pictor-mcp
A secure Model Context Protocol server for image operations, packaged as a hardened Docker image.
Point any MCP client at it and the model can inspect, convert, resize, compress, crop, rotate, watermark, background-remove and batch-process images — and verify its own edits afterwards.
mkdir -p input output
cp .env.example .env # set PICTOR_AUTH_TOKEN
docker compose up -d # pulls the published image; nothing is builtThe server is then at http://127.0.0.1:8077/mcp.
Contents
Related MCP server: makia-vision-mcp
Why this one
It is a file primitive, so it is treated like one. An image server that will read any path an agent names is arbitrary-file-read; one that writes any path is arbitrary-file-write. Reads are confined to configured roots, writes to a single output root, symlink escapes and traversal are refused, and the error never reveals whether a forbidden path exists.
It survives hostile images. Decompression bombs, over-long axes, animation amplification and truncated files are rejected before pixel data is allocated.
It returns what every client can use. Each result carries a self-contained
text summary, machine-readable structuredContent, an optional inline image for
vision models, and a resource link — so the same call works in a terminal, a
chat UI, or an agent loop. The inline image is off by default: base64 is
large and it would be repeated on every call, while the markdown URL in the text
is what a chat UI actually renders.
It speaks every MCP revision. The MCP Python SDK negotiates per connection:
modern stateless 2026-07-28 requests and legacy initialize-handshake clients
are served from the same endpoint with no compatibility flag.
It composes. image_transform runs an ordered operation list in one call, so
an agent does not pay a round trip and a base64 transfer per step.
Quick start
The compose files pull a prebuilt image from GHCR, so there is nothing to compile:
git clone https://github.com/TheRealChickenlegs/pictor-mcp.git
cd pictor-mcp
mkdir -p input output
cp .env.example .env
openssl rand -hex 32 # paste into PICTOR_AUTH_TOKEN in .env
# If your uid or gid is not 1000, put your own in .env (see below)
docker compose up -dVerify it:
docker pull ghcr.io/therealchickenlegs/pictor-mcp:latest # confirm the tag exists
curl -s http://127.0.0.1:8077/healthz # {"status":"ok"}
docker compose logs -f pictor-mcp
docker compose exec pictor-mcp python -m pictor_mcp --check # resolved config, secrets redactedPut images in ./input, and the server writes results to ./output.
The default port mapping is 127.0.0.1:8077:8077, so nothing off-host can
reach it. To expose it on your LAN, see
Exposing beyond localhost.
File ownership
The container runs as PUID:PGID, set in .env and defaulting to 1000:1000.
Point them at your own identity and everything the server writes to ./output
belongs to you, and ./input is readable without loosening anything:
id -u # e.g. 1000 -> PUID
id -g # e.g. 1000 -> PGIDThat is why the quick start above needs no chown. If you would rather not
change the container's identity, the other direction works too:
sudo chown -R 1000:1000 input output # match the container's defaultGetting this wrong is the one setup error you are likely to hit, and it is deliberately loud rather than silent — the server refuses to start rather than booting and failing every call:
configuration error: output root /data/output is not writable by uid 1000,
gid 1000: Permission denied. In Docker the container user must match the owner
of the mounted host directory ...Do not set PUID or PGID to 0. Running as root would negate
cap_drop, no-new-privileges and the read-only root filesystem, and it is
unnecessary: /data/output inside the image is writable by any uid precisely so
that this choice is free.
Which compose file
File | What it does |
| CPU image. The default; |
| Overlay: switches to the CUDA image and passes the GPU through. |
| Overlay: the ML image (CUDA + background removal, u2net baked in). |
| Overlay: build from this checkout instead of pulling. |
Overlays are combined with repeated -f, and they only add — the hardening,
volumes and environment all come from the base file, so a variant cannot drift
from it:
# GPU
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
# ML (background removal); do not combine with the GPU overlay, they use the
# same host port and both request the GPU
docker compose -f docker-compose.yml -f docker-compose.ml.yml up -dPinning a version
latest follows the default branch. For a reproducible deployment, set exact
tags in .env — none of these are read by the server, they are Compose knobs:
IMAGE_TAG=1.0.0
IMAGE_TAG_GPU=1.0.0-gpu
IMAGE_TAG_ML=1.0.0-mlEvery image also carries an immutable sha-<short> tag, which is the one to
pin if you want to be certain nothing moves.
Building locally instead of pulling
The CUDA and ML images are several gigabytes, and most of that is PyTorch's NVIDIA wheels. If you are changing the code, build the image where it runs and deploy a stack that names it: nothing is pushed to a registry and nothing is pulled.
make build-gpu # or: scripts/build_image.sh --target gpuThat builds the gpu stage into the local Docker daemon as
pictor-mcp:local-gpu, then starts it with --network none to prove it works
offline. Point the stack at it with IMAGE_REPO=pictor-mcp and
IMAGE_TAG_GPU=local-gpu; both, and the make targets for the other variants,
are in docs/portainer.md, which also covers having
Portainer build the image itself and triggering a redeploy from GitLab.
Rebuilds are cheap because the application is installed above every dependency
layer in the Dockerfile: a source edit rebuilds one small layer, and a
pyproject.toml change reinstalls from a pip cache mount instead of
re-downloading. make build does the CPU image in seconds.
Client setup
Every client below needs the same three things: the URL
http://<host>:8077/mcp, a bearer token, and nothing else.
DeepSeek Harness (DSH)
Add to your DSH plugin configuration. DSH exposes the tools as
mcp__pictor__image_resize and so on.
- id: mcp-pictor
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: pictor
transport: streamable-http
url: http://127.0.0.1:8077/mcp
headers:
Authorization: !!js '`Bearer ${process.env.PICTOR_AUTH_TOKEN}`'
toolCallTimeoutMs: 180000DSH renders the inline image content blocks, so a vision model can see the
result directly — set PICTOR_INLINE_IMAGES=true for that, since inlining is
off by default. Raise toolCallTimeoutMs above the default 60 s if you process
very large images.
OpenCode
In ~/.config/opencode/opencode.json (or a project opencode.json):
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"pictor": {
"type": "remote",
"url": "http://127.0.0.1:8077/mcp",
"enabled": true,
"headers": {
"Authorization": "Bearer ${PICTOR_AUTH_TOKEN}"
}
}
}
}For a local (stdio) server instead of Docker:
{
"mcp": {
"pictor": {
"type": "local",
"command": ["python", "-m", "pictor_mcp"],
"environment": {
"PICTOR_TRANSPORT": "stdio",
"PICTOR_INPUT_ROOTS": "/home/you/images",
"PICTOR_OUTPUT_ROOT": "/home/you/images/out"
},
"enabled": true
}
}
}Hermes agent
Hermes keeps MCP servers in ~/.hermes/config.yaml under mcp_servers, or you
can use the CLI: hermes mcp add.
mcp_servers:
pictor:
url: "http://127.0.0.1:8077/mcp"
headers:
Authorization: "Bearer YOUR_TOKEN_HERE"
timeout: 180
connect_timeout: 10Notes:
Omit
transportfor Streamable HTTP (the default). Addtransport: sseonly if you deliberately started the deprecated SSE transport.If Hermes' pre-flight content-type probe rejects the endpoint, set
skip_preflight: true— the probe expects a content type that a valid Streamable HTTP endpoint does not have to return.For a stdio server, replace
url/headerswithcommand: "python"andargs: ["-m", "pictor_mcp"], plus anenv:block.
Open WebUI
Open WebUI talks to MCP tool servers from its backend (server-side), so no browser-origin configuration is needed.
Admin Settings → Tools → Add Connection / MCP Servers, add a Streamable HTTP entry with URL
http://pictor-mcp:8077/mcp(container name, if Open WebUI shares a Docker network) orhttp://host.docker.internal:8077/mcpfrom a container to a host port.Set the auth header to
Authorization: Bearer <your token>.Make the tools available to a model, then ask it to resize an image.
Open WebUI specifics, all of them learned the hard way:
Images display through markdown in the text block, and nothing else works.
Reading Open WebUI's process_tool_result (and its MCP client) explains why:
Text blocks are joined. Exactly one text block becomes the tool result — two would be wrapped in a JSON object and
json.dumps-ed, and the markdown inside would render as escaped characters. This server always returns one.The
imagecontent block is dropped. Open WebUI builds its data URI fromitem["mimeType"], but its ownmodel_dump()renames the field tomime_typefirst, so it readsdata:None;base64,…and gives up. That is Open WebUI's bug and no MCP server using the official SDK models can work around it — which is why inlining defaults to off; the payload would be discarded anyway.resource_linkblocks are ignored.
So the display comes from the model repeating the  line this
server puts at the end of the result text, and the URL has to be
browser-reachable. Two settings, in order:
PICTOR_SERVE_OUTPUTS=true # without this a result carries no URL at all
PICTOR_PUBLIC_BASE_URL=https://pictor.example.comIf the model summarises without the link, say so in its system prompt:
When a pictor-mcp tool returns an image, emit its
line as live Markdown on its own line in your reply, exactly as given — never inside a code block or backticks.
When no image appears, find which link is broken before changing anything. Each command isolates one hop:
# 1. Does the server think links are on? configurationWarnings should be empty.
docker compose exec pictor-mcp python -m pictor_mcp --check
# 2. Does the public hostname reach this container from outside? 200 means yes.
curl -sS -o /dev/null -w '%{http_code}\n' https://pictor.example.com/healthz
# 3. Did the last tool call actually contain a URL? Look at the tool result in
# the chat: no "Image URL:" line means PICTOR_SERVE_OUTPUTS is still false.A 502/404 from step 2 is the common cause: the proxy serves /mcp but not
/files/, or the hostname is not routed to this container at all. The /files/
route is exempt from bearer auth — the HMAC signature in the URL is the
credential — but it is still subject to the Host check, so the public hostname
must be in PICTOR_ALLOWED_HOSTS. The server warns when it is not.
A link that opens perfectly in a new tab and still shows nothing in the chat
is a different failure, and it is a response header rather than a URL. Served
files carry Cross-Origin-Resource-Policy: cross-origin so another origin (Open
WebUI's) may embed them. Builds before that set same-origin on every response,
and a browser obeys it for an <img> while ignoring it for a top-level
navigation — so the URL checks out by hand and the picture stays blank, with only
a console error (net::ERR_BLOCKED_BY_RESPONSE, or a
Cross-Origin-Resource-Policy line) to show for it. If that is what you see,
rebuild the image. The API routes still send same-origin; only /files/
differs.
One more thing about links: they expire (PICTOR_URL_TTL_SECONDS, default one
hour), so a chat reopened tomorrow shows a broken image. Raise it if you want old
conversations to keep rendering.
If tool discovery fails, try PICTOR_STATELESS_HTTP=true (the default) and
PICTOR_JSON_RESPONSE=true. Some Open WebUI versions handle plain JSON
responses better than SSE streams.
Any other stdio client
The server runs as a normal stdio MCP server:
pip install .
PICTOR_INPUT_ROOTS=/path/to/images PICTOR_OUTPUT_ROOT=/path/to/out python -m pictor_mcpOr with Docker:
docker run -i --rm \
--user "$(id -u):$(id -g)" \
-v /path/to/images:/data/input:ro \
-v /path/to/out:/data/output \
-e PICTOR_TRANSPORT=stdio \
ghcr.io/therealchickenlegs/pictor-mcp:latest--user is what docker compose sets from PUID/PGID; without it the
container uses the image's own uid, and /path/to/out would need to be writable
by that account instead. See File ownership.
Tools
15 tools. image_capabilities reports exactly which are usable in your
deployment, so an agent can check rather than guess.
Inspect
Tool | Purpose |
| Formats, operations, limits, security posture, GPU status. |
| What the server can read, newest first, with the |
| Dimensions, format, mode, frames, EXIF, perceptual hashes, dominant colours. |
| SSIM, RMSE, PSNR, changed-pixel ratio and hash distance between two images. |
Transform
Tool | Purpose |
| Between JPEG, PNG, WebP, AVIF, TIFF, GIF, BMP, ICO, JPEG 2000, QOI, PPM. Also rasterises SVG input. |
| By width, height, percentage, or into a box. Six fit modes, six filters. |
| By quality, or search for the best quality under a target size. |
| Pixel box, aspect ratio with gravity, or auto-trim a border. |
| Any angle, mirror, and EXIF auto-orient. |
| Exact-size thumbnail, cropped by saliency rather than blindly centred. |
| Text or image, positioned, rotated, tiled, with opacity. |
| Colour keying (offline, instant) or an ML model. |
| A width ladder of web variants plus a placeholder and a |
Compose and batch
Tool | Purpose |
| An ordered operation pipeline in one call. |
| The same pipeline across many files, with per-file error isolation. |
image_transform
Prefer this when you need more than one step. Operations are validated before any pixels are touched:
{
"path": "photo.jpg",
"operations": [
{"op": "auto_orient"},
{"op": "resize", "width": 1200, "fit": "cover", "height": 630},
{"op": "watermark_text", "text": "DRAFT", "opacity": 0.35, "tile": true},
{"op": "sharpen", "amount": 1.1}
],
"output_format": "webp",
"quality": 82
}Available operations: auto_orient, resize, crop, rotate, flip,
sharpen, blur, smart_crop, watermark_text, watermark_image,
background_remove, feather.
Fit modes: contain (fit inside), cover (fill and crop), fill (stretch),
inside (contain, never enlarge), outside (cover, no crop), pad (contain
then pad to the exact box).
Image inputs
Every tool accepts exactly one of:
path— relative to an allowed root, or absolute inside one. Reads also work from the output root, so you can read back what the server just produced.base64_data— inline bytes, optionally adata:URI.url— HTTP(S), disabled by default and SSRF-guarded when enabled.
url is for images on the public internet and nothing else. The guard refuses
private, loopback and link-local addresses whatever the allow-list says, because
this server sits inside the network those addresses name — an image that only
exists on that network (a chat UI's own file endpoint, a NAS, another container)
has to be mounted under PICTOR_INPUT_ROOTS and passed as path. URLs needing
credentials are refused too, since the fetch carries none.
SVG input
SVG is accepted and rasterised, so image_convert(path="logo.svg", target_format="png") works. It is read-only: SVG is never an output format, and
it is never served inline, because an SVG document can carry script.
Pillow has no SVG decoder, so this is the one input that does not go through the shared decode path. Three things follow from that, and they are worth knowing before you point it at untrusted files:
It renders in a separate process. The renderer is a native library, and it overflows its stack and aborts on some small, shallow documents — a chain of 300 sibling
<pattern>elements, about 20 KB at XML depth 5, segfaultsresvg0.5.0 deterministically on macOS arm64, while the very same file renders on glibc x86_64. A segfault cannot be caught from Python, and the breaking point follows the stack rather than the document, so in-process rendering would mean one crafted file kills the server for every client. The child is expendable; a document that crashes it becomes an ordinary failed tool call.The document does not choose its own size.
width/heightis read, thenviewBox, thenPICTOR_SVG_DEFAULT_SIZE; the result is clamped toPICTOR_MAX_DIMENSIONandPICTOR_MAX_PIXELSbefore the renderer starts. A file claiming1000000x1000000is downscaled, not obeyed.It reaches nothing.
file:,http:andhttps:references are never resolved,DOCTYPEis ignored, and no entity is expanded against the network or the disk.PICTOR_SVG_SYSTEM_FONTS=falsegoes further and lets the renderer open no files at all, at the cost of<text>fidelity.
Set PICTOR_ALLOW_SVG=false to refuse SVG outright; the error then names the
setting instead of the generic "not a recognisable image". Nesting deeper than
PICTOR_SVG_MAX_DEPTH is refused before the renderer is involved, and a render
that exceeds PICTOR_SVG_TIMEOUT_SECONDS is killed. Real files exported from
Illustrator and Inkscape — DOCTYPE and all — render normally.
Attaching an image in a chat UI
The awkward case: the image lives in your chat UI's own storage, several people
upload to it, and nobody is going to copy files into a mount by hand. Mount that
storage read-only and it stops being awkward — the file is already there, and
image_list_inputs lets the model find it instead of guessing.
For Open WebUI, uploads are written flat into its uploads/ directory as
<file-id>_<original-name>, so a file attached in a chat is on disk as
3e6925c9-9b74-4437-ad9d-a246c127592a_Chickenlegs.png:
# where Open WebUI keeps its data, on the host:
docker inspect open-webui --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'# pictor-mcp stack: add the store as a second read-only input root
volumes:
- ${DOCKER_PATH}/pictor/input:/data/input:ro
- /path/to/open-webui/data/uploads:/data/uploads:ro
environment:
PICTOR_INPUT_ROOTS: /data/input,/data/uploads
PICTOR_SERVE_OUTPUTS: "true" # so the result renders back in the chatThe model then calls image_list_inputs (optionally pattern="*.png"), sees the
newest attachments with the exact path to use, and converts one:
image_list_inputs(pattern="*.png")
image_convert(path="3e6925c9-..._Chickenlegs.png", target_format="webp")Worth knowing before you mount it: every user's uploads become files that anyone
who can call the tools may read or enumerate. That is the same group of people
who can already see those images in the chat UI, but it is a filesystem promise
rather than a per-user API one. image_list_inputs skips dotfiles, symlinks and
anything that is not a regular file, and never leaves the configured roots.
How results come back
One call, four representations of the same artefact — pick whichever your client understands:
// 1. text — always present, self-contained
"Image resize completed.\nInput: 4000x3000 JPEG 3.1 MB\nOutput: 1200x900 WEBP 142.3 KB -> resized/photo-w1200.webp\nSize: 3.1 MB -> 142.3 KB (95.4% smaller)",
// 2. image — inline, for vision models (only when PICTOR_INLINE_IMAGES=true,
// return_image is true and it fits under the inline byte limit)
{"type": "image", "data": "<base64>", "mimeType": "image/webp"},
// 3. resource_link — for clients that resolve MCP resources
{"type": "resource_link", "uri": "pictor://outputs/resized/photo-w1200.webp", ...},
// 4. structuredContent — for programmatic callers
{
"ok": true,
"operation": "image_resize",
"outputs": [{
"name": "photo-w1200.webp", "path": "resized/photo-w1200.webp",
"mimeType": "image/webp", "format": "webp", "byteSize": 145715,
"width": 1200, "height": 900, "sha256": "…", "url": "http://…/files/…"
}],
"sizeChange": {"inputBytes": 3251840, "outputBytes": 145715, "savedPercent": 95.52}
}Base64 never appears twice: if the image is inlined, it is not also in the JSON.
Neither happens by default — block 2 is absent unless PICTOR_INLINE_IMAGES=true
and the caller asks (return_image: true), and return_base64 is false.
Errors are readable and machine-actionable. A refused path returns
is_error: true, a plain message (path is outside the configured input roots),
and a stable code in structuredContent.error.code. Messages never contain host
paths or library internals, so they are safe to show a model.
GPU acceleration
The CPU image is the default and needs no GPU. If you have an NVIDIA card, switch to the CUDA image with an overlay; acceleration is then detected automatically.
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -dRequires the NVIDIA Container Toolkit
and a driver visible to nvidia-smi. Sanity-check the host before blaming the
image:
docker run --rm --gpus all nvidia/cuda:12.6.0-base-ubuntu22.04 nvidia-smiThe GPU tags are :gpu and :<version>-gpu; the overlay selects them for you,
and IMAGE_TAG_GPU pins an exact one.
The CUDA line must cover your card's architecture. The image installs a
CUDA 12.8 build (cu128), which carries kernels for Turing through Blackwell
(RTX 50-series). A card newer than the build will load, appear available, and
then fail every kernel launch. The server detects this at startup, disables the
GPU, and says so in plain terms:
GPU acceleration unavailable: RTX 5060 Ti (torch ..., cuda ...) is sm_120, which
this PyTorch build has no kernels for; it supports sm_50, ..., sm_90. This build
predates that architecture. Rebuild with a CUDA 12.8 or newer wheel index ...The same text appears in image_capabilities, so an agent can report it. To
change it, override the index at build time — and note the coupling, since an
index only helps if it still publishes wheels for the base image's Python:
build:
args:
TORCH_INDEX_URL: "https://download.pytorch.org/whl/cu129"Check a pairing before committing to it:
curl -s https://download.pytorch.org/whl/cu128/torch/ | grep -o 'cp3[0-9]*' | sort -uWhat actually speeds up, and what does not:
Resampling (resize) runs on the GPU via
torch.nn.functional.interpolate. It is only used abovePICTOR_GPU_MIN_PIXELS(default 4 MP), because below that the host↔device copy costs more than the resize.Encoding stays on the CPU. libjpeg-turbo and libwebp have no CUDA path in Pillow and are already fast.
Lanczos is approximated by bicubic with antialiasing, since torch has no Lanczos kernel. The result notes say so when it happens.
Background removal with
method: "ml"usesonnxruntime-gpuin themlimage.
The GPU is an accelerator, never a requirement. On startup the server resizes a test image on both paths and compares them; if they disagree beyond tolerance the backend disables itself and logs why. A missing driver, an out-of-memory card or an unsupported pixel format degrades throughput, never correctness.
# Ask the server what it is actually using
# (call the image_capabilities tool, or read the startup log line)
docker compose exec pictor-mcp python -m pictor_mcp --check | grep -i accelThe ml image
Adds rembg + onnxruntime-gpu on top of the CUDA image, with the u2net
segmentation weights baked in at build time. That baking is the point: the
container needs no network at runtime, which is what makes it usable on an
isolated internal network.
docker compose -f docker-compose.yml -f docker-compose.ml.yml up -dIt is substantially larger (roughly a gigabyte of model and ONNX runtime on top
of the CUDA wheels), so use it only if you actually need ML background removal.
The CPU image can already remove a flat background exactly and instantly with
method: "color", which covers the usual product or logo shot.
Only u2net ships inside the image, so only that model works offline. Any other
PICTOR_BG_MODEL is downloaded on first use and needs a writable or pre-seeded
directory at U2NET_HOME — see the commented volume in docker-compose.ml.yml.
Do not combine the ml and gpu overlays: they publish the same host port and
both request the GPU.
Building from source
For a local modification, add the build overlay. BUILD_TARGET selects the
Dockerfile stage and LOCAL_IMAGE_TAG names the result, so a CUDA build cannot
land under the CPU tag:
BUILD_TARGET=gpu LOCAL_IMAGE_TAG=local-gpu docker compose \
-f docker-compose.yml -f docker-compose.gpu.yml -f docker-compose.build.yml \
up -d --buildmake build-gpu (and make up-gpu) wrap exactly that, plus a post-build check
that the image starts with no network. See
Building locally instead of pulling.
Security
Read SECURITY.md for the full threat model. The short version:
Risk | Control |
Arbitrary file read | All reads confined to |
Arbitrary file write | All writes confined to |
TOCTOU / symlink swap | Files are opened with |
Decompression bombs | Pixel, per-axis, frame and file-size ceilings enforced before allocation. |
Crashing SVG renderer | SVG rasterisation runs in a separate process, so a document that overflows the renderer's stack (a 20 KB |
SSRF via URL input | Off by default. When on: scheme/port/host allow-lists, all resolved addresses must be globally routable, connection pinned to the validated IP, per-hop redirect validation, streaming size cap. |
DNS rebinding | Host and Origin headers validated on every route, including health and file serving. |
Unauthenticated access | Optional bearer token, compared in constant time. Fail-closed configuration: serving files without a credential refuses to start. |
Metadata leakage | EXIF, GPS and ICC stripped by default. |
Stored XSS via output files | Served content types are allow-listed to images; anything else is an opaque attachment, every response carries a sandbox CSP, and |
Unbounded intermediate allocation |
|
Container breakout | Non-root runtime ( |
Resource exhaustion | Concurrency cap, per-call timeouts, request body cap, batch limits. |
Secret leakage in logs | Startup secrets are redacted; stdout is never used for logging (it is the stdio protocol channel). |
Exposing beyond localhost
The default port mapping binds to loopback, so nothing off-host can reach the server. Making it reachable is a three-part change, and doing only the first part is the mistake worth avoiding.
1. Bind the port. The published port is controlled by BIND_ADDRESS in
.env, which is easier to switch back than editing the mapping:
# .env - reachable from anything that can route to this host
BIND_ADDRESS=0.0.0.0The ports: block in docker-compose.yml also lists the equivalent mappings as
commented alternatives, including binding one specific interface
(192.168.1.10:8077:8077), which is the safest option when the host has several
addresses (VPN, docker0, a second NIC).
2. Set the allow-lists to match, or browser-originated requests are refused by design. The defaults name only loopback and the compose service name:
PICTOR_ALLOWED_HOSTS=192.168.1.10:8077,pictor.internal:8077
PICTOR_ALLOWED_ORIGINS=http://192.168.1.10:8077Non-browser MCP clients (DSH, OpenCode, Hermes, Open WebUI's backend) send no
Origin header and are unaffected either way, which is why the loopback default
is safe. A browser-based client is the case that needs the allow-list.
Both lists accept a small pattern grammar: a bare host or host:port matches
exactly, host:* matches that host on any port, *.example.com matches any
subdomain on any port (never the apex itself), and * matches everything — only
sane behind a hardened proxy. PICTOR_ALLOWED_ORIGINS takes the same forms with
an optional scheme, e.g. https://*.example.com. These patterns are interpreted
by this server; the MCP SDK's weaker built-in Host check is disabled so there is
only one answer to "is this request allowed?".
3. Always set PICTOR_AUTH_TOKEN once anything but you can reach the port.
Anyone who can reach an unauthenticated instance has the full tool surface, which
reads and writes files.
Better still, terminate TLS in a reverse proxy and set PICTOR_ENABLE_HSTS=true;
the server speaks plain HTTP by design, so put Caddy/nginx/Traefik in front if
the network is not trusted. When the proxy makes the external host or port differ
from the bind address, also set PICTOR_PUBLIC_BASE_URL so generated file links
point somewhere useful.
Put the public hostname in the allow-list, and give it a port wildcard.
Requests that arrive through the proxy carry the public Host header, not the
internal one, so a domain that is not listed is refused — and because the MCP
client usually talks to pictor-mcp:8077 directly over the docker network while
the browser fetches generated files through the proxy, the same server can work
for tools and fail for every image:
PICTOR_ALLOWED_HOSTS=127.0.0.1:*,localhost:*,pictor-mcp:*,pictor.example.com:*A bare hostname also matches :80 and :443, since those name the same
authority a proxy may forward. Any other port needs the :* form — which is
why the example above uses it.
Connecting from another container
This is not the same as exposing the port, and the port mapping is irrelevant to
it: containers on one Docker network reach each other by service name on the
container's own port, never through the host's published port. A client that
calls http://pictor-mcp:8077/mcp sends Host: pictor-mcp:8077, so the
PICTOR_ALLOWED_HOSTS default includes pictor-mcp:* and this works with no
extra configuration.
Renaming the service in docker-compose.yml renames the Host header too, so
update the allow-list to match. If a client is refused with
rejected request: host header 'X' is not allowedthen X is the value to add to PICTOR_ALLOWED_HOSTS — an aggregator that
reaches the server through a reverse proxy, or by a LAN name, sends that name
instead. The rejection is logged before authentication is checked, so a Host
rejection is not an indication that the token is wrong; fix the Host list first,
then confirm the token.
Generated file URLs
With PICTOR_SERVE_OUTPUTS=true, results include a URL so a web UI can render
the image. Those URLs are not bearer-authenticated — they carry an HMAC
signature with an expiry, scoped to one file, which is what lets a browser
<img> tag work without the API token:
https://pictor.example.com/files/1789692769.auyHow_iQuQkfvL_Ta9YJw/converted/photo.png
└──────── expiry . signature ────────┘The credential is a path segment, not a query parameter, and that is
deliberate. A signed URL has to survive being copied out of a tool result by a
language model and pasted into a reply, then rendered through markdown and HTML
by a chat UI, then forwarded by a reverse proxy. ?e=…&s=… lost to all three:
models drop what reads as noisy parameters, HTML escaping turns & into
&, and proxies rewrite or strip queries. Anything that keeps the path
keeps the credential, and there is no & to escape. If a link is refused, the
container log says which of the reasons it was.
The server refuses to start with
PICTOR_SERVE_OUTPUTS=trueunlessPICTOR_AUTH_TOKENorPICTOR_URL_SECRETis set, so this can never silently become an open directory of everything the server has produced.Set
PICTOR_PUBLIC_BASE_URLwhen the externally visible host/port differs from the bind address (reverse proxy, different published port), or the generated links will point at127.0.0.1:<PICTOR_PORT>.PICTOR_URL_TTL_SECONDS(default 3600) bounds how long a leaked link works.The
/files/route needs the public hostname inPICTOR_ALLOWED_HOSTS, with a port wildcard if the proxy forwards anything but 80/443. A link that is refused returns 403 with no image, so the browser shows "image unavailable"; the container log now names the file and the reason, which is where to look first when a chat UI shows a broken picture.The result text ends with the exact
line to paste into a reply, because that is how a chat UI ends up displaying it — see Attaching an image in a chat UI and the Open WebUI notes. SettingPICTOR_PUBLIC_BASE_URLwhile this is off is inert, and the server warns about it at startup.
Configuration
Every setting is an environment variable prefixed PICTOR_. Full annotated list
with defaults: .env.example. Validate a deployment without
starting it:
docker compose exec pictor-mcp python -m pictor_mcp --checkThe most consequential ones:
Variable | Default | Notes |
|
|
|
|
| Loopback by default outside Docker. |
|
| Read roots, comma-separated, absolute. |
|
| The only writable directory. Must not overlap an input root. |
| (empty) | Required for HTTP once reachable off-host. Minimum 16 characters. |
|
| Enables URL inputs, with the SSRF guard. |
|
| Rasterise SVG input in an isolated child process. Set |
|
| Signed URLs for generated files. Set |
| (empty) | External base URL for generated links. Its host must be in |
|
| Inline base64 image in results. Off by default: large, and repeated on every call. |
|
| Best client compatibility. |
|
| Per-frame decompression-bomb ceiling. |
|
| Ceiling on width × height × frames. |
|
| Cooperative budget, checked between pipeline steps and quality probes. |
|
| uvicorn access log; off so signed URLs are not written to logs. |
|
| Concurrent operations; size with |
|
| Strip EXIF/GPS/ICC from outputs. |
| loopback + | Host headers accepted. Add a name if a client is refused; see Connecting from another container. |
|
|
|
Invalid values make the server refuse to start rather than fall back to a less safe default.
Separately, section 0 of .env.example holds the Compose-only settings.
They have no PICTOR_ prefix precisely so they cannot be mistaken for server
options:
Variable | Default | Notes |
|
| Registry path, no tag. |
|
| CPU image tag. Pin e.g. |
|
| CUDA image tag, e.g. |
|
| ML image tag, e.g. |
|
| Host interface the port binds to. |
|
| uid:gid the container runs as. Set to |
Running without Docker
Requires Python 3.10+.
python -m venv .venv && source .venv/bin/activate
pip install . # or ".[gpu]", ".[bg]", ".[all]"
PICTOR_TRANSPORT=stdio \
PICTOR_INPUT_ROOTS="$HOME/Pictures" \
PICTOR_OUTPUT_ROOT="$HOME/Pictures/out" \
python -m pictor_mcpFor an HTTP server natively, set PICTOR_TRANSPORT=streamable-http and
PICTOR_HOST=127.0.0.1 (a non-loopback host with no explicit
PICTOR_ALLOWED_HOSTS accepts any Host header — pair the two).
Container images
Three variants are published to the GitHub Container Registry on every push to
the default branch and every v* tag:
Tag | Contents | Platforms |
| CPU, ~180 MB |
|
| CPU + PyTorch CUDA wheels |
|
| GPU + rembg + |
|
Version tags are added alongside (1.0.0, 1.0, 1.0.0-gpu, …), plus an
immutable sha-<short> tag per commit. The CUDA images are amd64-only because
PyTorch does not publish linux/arm64 wheels for the CUDA index they install
from. Images carry a signed build-provenance attestation and an SBOM.
The compose files already point at these tags, so docker compose up -d pulls
rather than builds. Override IMAGE_REPO if you mirror them elsewhere, and
IMAGE_TAG* to pin versions.
A published image reports the version baked into it, so you can always tell what you are running:
docker run --rm ghcr.io/therealchickenlegs/pictor-mcp:latest python -m pictor_mcp --versionDevelopment
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
ruff check src tests
ruff format src tests
pytest -qThe suite (≈400 tests) covers path confinement and symlink escapes, SSRF classification of internal address ranges, decompression bombs, format allow-listing, encoder fallbacks, pipeline semantics, and both transports end-to-end — including a live HTTP server for the auth, DNS-rebinding and signed URL behaviour.
Checks
Pull requests run five jobs, all of which must pass:
Job | What it enforces |
Lint, format and syntax |
|
Lint the workflows |
|
Tests | The full suite on Python 3.10, 3.11, 3.12 and 3.13 — the declared floor, the version the image ships, and the newest the SDK supports. |
Build and install the distribution | Builds the sdist and wheel, installs the wheel into a clean venv, runs the console script, and smoke-tests the installed server over stdio. |
Deployment files | Parses |
CodeQL runs security-extended analysis on pushes, pull requests and weekly.
Dependabot keeps the actions, Python dependencies and base image current.
Layout:
src/pictor_mcp/
├── config.py environment parsing, fail-loud
├── server.py transports, middleware, wiring
├── outputs.py result envelope
├── models.py public result schema
├── errors.py error taxonomy with stable codes
├── security/ path jail, limits, SSRF guard, auth, concurrency
├── imaging/ formats, loader, ops, encode, pipeline, analysis
├── backends/ pluggable CPU/CUDA resampling
└── tools/ the 15 MCP toolsLicense
MIT. See LICENSE.
Available Tools
14 toolsimage_background_removeRemove an image backgroundA
Make the background transparent. method='color' keys out a flat border colour (instant, offline, best for product/logo shots); method='ml' uses a U^2-Net model for arbitrary backgrounds; method='auto' picks ML when the model is installed.
Inputs: url, path, model, method, +11 more. Returns: Standard envelope returned by every image tool. ``ok`` is always true on the su…
image_batchApply a pipeline to many imagesA
Run the same operation list across every image in a directory, a glob, or an explicit list of paths. Each file is independent: a failure is reported per file and does not abort the run. Use a {name} placeholder in output_name for per-file suffixes.
Inputs: operations (required), glob, limit, paths, +5 more. Returns: Result of a batch operation.
image_capabilitiesImage server capabilitiesA
Report what this image server can do: supported formats, available operations, resource limits, security posture, and whether GPU acceleration is active. Call this first when you are unsure which formats or features are available.
Inputs: no named parameters. Returns: Runtime capability report.
image_compareCompare two imagesA
Measure how different two images are: SSIM, RMSE, PSNR, mean and max pixel difference, the fraction of changed pixels, and perceptual-hash distance. Use it to verify a transform or to spot duplicate images.
Inputs: url, path, align, base64_data, +6 more. Returns: Standard envelope returned by every image tool. ``ok`` is always true on the su…
image_compressCompress an imageA
Reduce an image's file size, either by a quality level or by searching for the best quality that stays under a target size. Reports the exact saving achieved.
Inputs: url, path, effort, quality, +8 more. Returns: Standard envelope returned by every image tool. ``ok`` is always true on the su…
image_convertConvert image formatB
Convert an image between formats (JPEG, PNG, WebP, AVIF, TIFF, GIF, BMP, ICO, JPEG 2000, QOI, PPM), controlling quality, losslessness and metadata.
Inputs: url, path, effort, quality, +10 more. Returns: Standard envelope returned by every image tool. ``ok`` is always true on the su…
image_cropCrop an imageA
Crop by explicit pixel box, by aspect ratio with a gravity anchor, or auto-trim a uniform border.
Inputs: box, url, path, trim, +11 more. Returns: Standard envelope returned by every image tool. ``ok`` is always true on the su…
image_infoInspect an imageA
Read an image's metadata without modifying it: dimensions, format, colour mode, animation frames, EXIF, perceptual hashes and dominant colours.
Inputs: url, path, base64_data, include_exif, +3 more. Returns: Result of inspecting an image without modifying it.
image_optimize_webProduce responsive web variantsA
Turn one source image into a set of web-ready variants at several widths, each optionally constrained to a maximum file size, plus a tiny blurred placeholder and a ready-to-paste srcset. This is the one call to use for 'make this web-ready'.
Inputs: url, path, widths, quality, +5 more. Returns: Standard envelope returned by every image tool. ``ok`` is always true on the su…
image_resizeResize an imageB
Resize by width, height, percentage, or to fit a box. Supports contain, cover, fill, inside, outside and pad fit modes, all resampling filters, and optional format change.
Inputs: fit, url, path, width, +13 more. Returns: Standard envelope returned by every image tool. ``ok`` is always true on the su…
image_rotateRotate or flip an imageA
Rotate by any angle, mirror horizontally or vertically, and/or apply the EXIF orientation so the pixels match what a viewer shows.
Inputs: url, flip, path, angle, +10 more. Returns: Standard envelope returned by every image tool. ``ok`` is always true on the su…
image_thumbnailCreate a thumbnailB
Produce a thumbnail of an exact size, choosing the crop by saliency (attention or entropy) instead of blindly taking the centre.
Inputs: url, path, size, method, +9 more. Returns: Standard envelope returned by every image tool. ``ok`` is always true on the su…
image_transformApply an ordered image pipelineA
Run several operations in one call, in order. Prefer this over chaining single-step tools: it transfers the image once and applies every step server-side. Operations: auto_orient, resize, crop, rotate, flip, sharpen, blur, smart_crop, watermark_text, watermark_image, background_remove, feather.
Inputs: operations (required), url, path, quality, +7 more. Returns: Standard envelope returned by every image tool. ``ok`` is always true on the su…
image_watermarkWatermark an imageA
Add a text or image watermark with position, opacity, rotation and optional tiling. Supply either 'text' or 'watermark_path'.
Inputs: url, path, text, tile, +19 more. Returns: Standard envelope returned by every image tool. ``ok`` is always true on the su…
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 14 tools
Several single-step tools overlap heavily with image_transform, which already supports resize, crop, rotate, watermark, and background removal. An agent deciding between image_resize and image_transform with a resize operation, or between image_thumbnail and image_resize, will face real ambiguity. A few tools like image_info and image_compare are distinct, but the manipulation subset is not clearly delineated.
All tools share the image_ prefix and use lower_snake_case, giving the set a coherent visual pattern. The second part is usually a verb (resize, convert, compress, crop, rotate, compare), though a few nouns appear (capabilities, info, thumbnail, watermark, batch). This is a minor deviation rather than a chaotic mix.
Fourteen tools is on the higher end but still believable for a general-purpose image manipulation server. The count is justifiable given the range of operations, batch processing, web optimization, and comparison features. It is not bloated, though the redundancy with image_transform makes the number feel slightly larger than necessary.
The surface covers a full image lifecycle: inspect, resize, crop, rotate, convert, compress, watermark, remove backgrounds, batch-process, compare, and produce web-ready output. It also includes capabilities reporting and metadata extraction, so there are no obvious dead ends or missing core operations. The composite transform fills in extra operations like sharpen, blur, and feather.
Related MCP Connectors
Image infrastructure for agents. Upload, transform, and deliver images through MCP, CLI, or API.
Image processing over MCP: compress and split images, generate images and QR codes.
Convert images to PNG, JPEG, WebP, or AVIF through one public remote MCP tool.
Edit images in one call: resize, convert, compress, crop, metadata, favicons, QR codes, OCR.
Related MCP Servers
- AlicenseAqualityDmaintenanceLocal image processing MCP server providing background removal, stock media search, resize, format conversion, and collage creation.5MIT
- FlicenseNot gradedqualityCmaintenanceEnables MCP clients to analyze, inspect, and manipulate images and videos with deterministic FFmpeg/sharp tools and pluggable AI providers, returning timestamped, evidence-backed results that can be independently re-verified.1-
- AlicenseNot gradedqualityCmaintenanceEnables cloud-based MCP clients to generate, edit, retrieve, and download full-resolution images through an existing CLIProxyAPI image endpoint, with reusable signed download links and automatic retention cleanup.2MIT
- AlicenseNot gradedqualityBmaintenanceEnables local image processing through ImageMagick, letting MCP clients read image metadata, resize, convert between formats, crop, and rotate image files entirely on the host with no network calls.282 npmMIT