Skip to main content
Glama

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 built

The 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 -d

Verify 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 redacted

Put 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  ->  PGID

That 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 default

Getting 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

docker-compose.yml

CPU image. The default; docker compose up -d.

docker-compose.gpu.yml

Overlay: switches to the CUDA image and passes the GPU through.

docker-compose.ml.yml

Overlay: the ML image (CUDA + background removal, u2net baked in).

docker-compose.build.yml

Overlay: build from this checkout instead of pulling. BUILD_TARGET picks the variant, LOCAL_IMAGE_TAG names the result.

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 -d

Pinning 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-ml

Every 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 gpu

That 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: 180000

DSH 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: 10

Notes:

  • Omit transport for Streamable HTTP (the default). Add transport: sse only 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/headers with command: "python" and args: ["-m", "pictor_mcp"], plus an env: block.

Open WebUI

Open WebUI talks to MCP tool servers from its backend (server-side), so no browser-origin configuration is needed.

  1. 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) or http://host.docker.internal:8077/mcp from a container to a host port.

  2. Set the auth header to Authorization: Bearer <your token>.

  3. 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 image content block is dropped. Open WebUI builds its data URI from item["mimeType"], but its own model_dump() renames the field to mime_type first, so it reads data: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_link blocks are ignored.

So the display comes from the model repeating the ![image](url) 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.com

If the model summarises without the link, say so in its system prompt:

When a pictor-mcp tool returns an image, emit its ![image](url) 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_mcp

Or 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

image_capabilities

Formats, operations, limits, security posture, GPU status.

image_list_inputs

What the server can read, newest first, with the path to pass on.

image_info

Dimensions, format, mode, frames, EXIF, perceptual hashes, dominant colours.

image_compare

SSIM, RMSE, PSNR, changed-pixel ratio and hash distance between two images.

Transform

Tool

Purpose

image_convert

Between JPEG, PNG, WebP, AVIF, TIFF, GIF, BMP, ICO, JPEG 2000, QOI, PPM. Also rasterises SVG input.

image_resize

By width, height, percentage, or into a box. Six fit modes, six filters.

image_compress

By quality, or search for the best quality under a target size.

image_crop

Pixel box, aspect ratio with gravity, or auto-trim a border.

image_rotate

Any angle, mirror, and EXIF auto-orient.

image_thumbnail

Exact-size thumbnail, cropped by saliency rather than blindly centred.

image_watermark

Text or image, positioned, rotated, tiled, with opacity.

image_background_remove

Colour keying (offline, instant) or an ML model.

image_optimize_web

A width ladder of web variants plus a placeholder and a srcset.

Compose and batch

Tool

Purpose

image_transform

An ordered operation pipeline in one call.

image_batch

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 a data: 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, segfaults resvg 0.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/height is read, then viewBox, then PICTOR_SVG_DEFAULT_SIZE; the result is clamped to PICTOR_MAX_DIMENSION and PICTOR_MAX_PIXELS before the renderer starts. A file claiming 1000000x1000000 is downscaled, not obeyed.

  • It reaches nothing. file:, http: and https: references are never resolved, DOCTYPE is ignored, and no entity is expanded against the network or the disk. PICTOR_SVG_SYSTEM_FONTS=false goes 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 chat

The 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 -d

Requires 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-smi

The 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 -u

What actually speeds up, and what does not:

  • Resampling (resize) runs on the GPU via torch.nn.functional.interpolate. It is only used above PICTOR_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" uses onnxruntime-gpu in the ml image.

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 accel

The 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 -d

It 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 --build

make 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 PICTOR_INPUT_ROOTS + the output root. Traversal, symlink escapes, absolute paths and special files (FIFOs, devices) refused.

Arbitrary file write

All writes confined to PICTOR_OUTPUT_ROOT, written atomically with O_NOFOLLOW.

TOCTOU / symlink swap

Files are opened with O_NOFOLLOW and re-verified through /proc/self/fd after opening.

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 <pattern> chain does it reliably) fails one tool call instead of killing the server. Its declared size is clamped to the pixel ceilings before it starts, nesting beyond PICTOR_SVG_MAX_DEPTH is refused, and file:/http: references and XML entities are never resolved.

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 output_name cannot choose its own extension.

Unbounded intermediate allocation

cover/outside resizes and saliency thumbnails bound the scaled bitmap, not just the result; image_compare processes in strips so memory is independent of image size.

Container breakout

Non-root runtime (PUID:PGID, default 1000:1000, never 0), read-only root filesystem, all capabilities dropped, no-new-privileges, PID and memory limits.

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.0

The 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:8077

Non-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 allowed

then 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 &amp;, 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=true unless PICTOR_AUTH_TOKEN or PICTOR_URL_SECRET is set, so this can never silently become an open directory of everything the server has produced.

  • Set PICTOR_PUBLIC_BASE_URL when the externally visible host/port differs from the bind address (reverse proxy, different published port), or the generated links will point at 127.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 in PICTOR_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 ![image](url) 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. Setting PICTOR_PUBLIC_BASE_URL while 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 --check

The most consequential ones:

Variable

Default

Notes

PICTOR_TRANSPORT

stdio (streamable-http in Docker)

stdio, streamable-http or sse.

PICTOR_HOST / PICTOR_PORT

127.0.0.1 / 8077

Loopback by default outside Docker.

PICTOR_INPUT_ROOTS

/data

Read roots, comma-separated, absolute.

PICTOR_OUTPUT_ROOT

/data/output

The only writable directory. Must not overlap an input root.

PICTOR_AUTH_TOKEN

(empty)

Required for HTTP once reachable off-host. Minimum 16 characters.

PICTOR_ALLOW_NET_FETCH

false

Enables URL inputs, with the SSRF guard.

PICTOR_ALLOW_SVG

true

Rasterise SVG input in an isolated child process. Set false to refuse SVG entirely.

PICTOR_SERVE_OUTPUTS

false

Signed URLs for generated files. Set true for a chat UI to display results.

PICTOR_PUBLIC_BASE_URL

(empty)

External base URL for generated links. Its host must be in PICTOR_ALLOWED_HOSTS.

PICTOR_INLINE_IMAGES

false

Inline base64 image in results. Off by default: large, and repeated on every call. true only for a vision client that cannot fetch a URL; Open WebUI discards the block either way.

PICTOR_STATELESS_HTTP

true

Best client compatibility.

PICTOR_MAX_PIXELS

64000000

Per-frame decompression-bomb ceiling.

PICTOR_MAX_ANIMATION_PIXELS

128000000

Ceiling on width × height × frames.

PICTOR_OP_TIMEOUT_SECONDS

120

Cooperative budget, checked between pipeline steps and quality probes.

PICTOR_HTTP_ACCESS_LOG

false

uvicorn access log; off so signed URLs are not written to logs.

PICTOR_MAX_CONCURRENCY

4

Concurrent operations; size with mem_limit.

PICTOR_STRIP_METADATA

true

Strip EXIF/GPS/ICC from outputs.

PICTOR_ALLOWED_HOSTS

loopback + pictor-mcp:* in Docker

Host headers accepted. Add a name if a client is refused; see Connecting from another container.

PICTOR_GPU

off (auto in the GPU image)

auto, off or torch.

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

IMAGE_REPO

ghcr.io/therealchickenlegs/pictor-mcp

Registry path, no tag.

IMAGE_TAG

latest

CPU image tag. Pin e.g. 1.0.0.

IMAGE_TAG_GPU

gpu

CUDA image tag, e.g. 1.0.0-gpu.

IMAGE_TAG_ML

ml

ML image tag, e.g. 1.0.0-ml.

BIND_ADDRESS

127.0.0.1

Host interface the port binds to. 0.0.0.0 for LAN.

PUID / PGID

1000 / 1000

uid:gid the container runs as. Set to id -u / id -g so ./output files are yours. Never 0.


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_mcp

For 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

ghcr.io/therealchickenlegs/pictor-mcp:latest

CPU, ~180 MB

linux/amd64, linux/arm64

ghcr.io/therealchickenlegs/pictor-mcp:gpu

CPU + PyTorch CUDA wheels

linux/amd64

ghcr.io/therealchickenlegs/pictor-mcp:ml

GPU + rembg + onnxruntime-gpu, u2net baked in

linux/amd64

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 --version

Development

python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

ruff check src tests
ruff format src tests
pytest -q

The 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

ruff check (which parses every file, so syntax errors surface first), ruff format --check, and compileall.

Lint the workflows

actionlint, whose image bundles shellcheck and pyflakes, so the shell and Python embedded in the workflows are checked too.

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 docker-compose.yml, the GPU overlay and .env.example and pushes them through the real config parser, so a renamed setting or a lost hardening flag fails the build.

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 tools

License

MIT. See LICENSE.

Available Tools

14 tools
image_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

    A3.6/5.0

    Scored across 14 tools

    Disambiguation2/5

    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.

    Naming Consistency4/5

    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.

    Tool Count4/5

    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.

    Completeness5/5

    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

    Related MCP Servers