@keohanoi/vision-mcp
v0.3.1
Published
MCP server that gives any text-only LLM vision — image analysis, OCR, comparison, region zoom, and video frame analysis.
Downloads
640
Maintainers
Readme
vision-mcp
An MCP server that gives a text-only coding agent vision, using the z.ai coding plan you already have.
The z.ai coding plan's text/coding models have no vision — they reject image input. But glm-4.6v is a multimodal model on that same plan, reachable at the same coding-plan endpoint (https://api.z.ai/api/coding/paas/v4) with the same API key and same billing. vision-mcp is the bridge: it accepts an image (or a video, or a cropped region) plus a prompt, forwards it to glm-4.6v on your plan, and returns text. It exposes six tools over stdio — including adaptive inspection (inspect_image), video analysis (ffmpeg frame extraction), and region zoom (sharp) for fine detail — to any MCP client. Every request flows through a pipeline that adds bounded retries, opt-in content-addressed caching, and optional request metadata. The default backend is z.ai GLM-4.6V (OpenAI Chat Completions format); an Anthropic-format provider is also supported for other gateways.
The published npm package @keohanoi/vision-mcp runs under Node.js ≥ 18 via npx — no install step. For development / from-source, bun runs the TypeScript directly with no build step.
Why
The z.ai coding plan already bundles a vision-capable model alongside its text/coding models — but the models you actually drive (and most agent defaults) are text-only and can't see images. This server routes image/OCR/video calls to glm-4.6v on your same plan, same API key, same billing — no separate vision subscription and no second account.
Use it whenever your coding agent needs to:
- Read a screenshot or UI mock
- OCR an error message, stack trace, log, or config from an image
- Compare two UI states (before/after, diffs)
- Summarize a video by sampled frames
Works with any MCP client — Claude Code, OpenCode, and anything else that speaks MCP over stdio.
Prerequisites
- Node.js ≥ 18 — required to run the published package via
npx - ffmpeg +
ffprobeon PATH — only required for theanalyze_videotool
For from-source / development (see From source (development) below): bun, and sharp (installed automatically by bun install; used by analyze_image / extract_text / analyze_region for crop, resize, and format control).
Install
No install step is needed for normal use — npx fetches @keohanoi/vision-mcp on first run. See Quick start (published package) below.
For development / running from source, see From source (development) and run bun install in the repo.
Configure
Copy .env.example to .env and fill in the values for the provider you want to use.
cp .env.example .envQuick start (published package)
The published npm package @keohanoi/vision-mcp is the primary install/config path. It runs under Node via npx — no clone, no build.
Prerequisites
- Node.js ≥ 18 (npx runs the package under Node)
- ffmpeg/ffprobe on PATH — only required if using the
analyze_videotool
Configure
Add the server to your MCP client config (e.g. Claude Code's mcpServers, OpenCode, etc.):
{
"mcpServers": {
"vision": {
"command": "npx",
"args": ["-y", "@keohanoi/vision-mcp"],
"env": {
"VISION_PROVIDER": "openai",
"VISION_BASE_URL": "https://api.z.ai/api/coding/paas/v4",
"VISION_API_KEY": "your-z.ai-coding-plan-key",
"VISION_MODEL": "glm-4.6v"
}
}
}
}CLI alternative (the VISION_* env vars must be exported in your shell first when using this form):
claude mcp add vision -- npx -y @keohanoi/vision-mcpEnvironment variables
| Variable | Required | Default | Description |
|---|---|---|---|
| VISION_PROVIDER | no | openai | anthropic (→ /v1/messages), openai (→ {base}/chat/completions), or responses (→ {base}/responses, OpenAI Responses API). Defaults to openai (z.ai) |
| VISION_BASE_URL | no (openai) / yes (anthropic) | https://api.z.ai/api/coding/paas/v4 (when provider=openai) | Recommended. Collision-safe alias; takes precedence over the provider-specific var below |
| VISION_API_KEY | yes | — | Recommended. Collision-safe alias for the API key |
| VISION_MODEL | no (openai) / yes (anthropic) | glm-4.6v (when provider=openai) | Recommended. Collision-safe alias; must be a multimodal/vision model. On the z.ai coding plan: glm-4.6v (default), also glm-4.5v and glm-4.6v-flash |
| VISION_THINKING | no | disabled | enabled | disabled — controls GLM thinking mode. Default disabled for fast/cheap perception & OCR |
| ANTHROPIC_BASE_URL | anthropic | — | Anthropic-format endpoint (no /v1; the SDK appends paths). Used only if VISION_BASE_URL is unset |
| ANTHROPIC_API_KEY | anthropic | — | API key. Used only if VISION_API_KEY is unset |
| ANTHROPIC_MODEL | anthropic | — | Must be a multimodal/vision model. Used only if VISION_MODEL is unset |
| OPENAI_BASE_URL | openai | — | OpenAI-format endpoint. Does not have to end in /v1 (z.ai ends in /v4). Used only if VISION_BASE_URL is unset |
| OPENAI_API_KEY | openai | — | API key. Used only if VISION_API_KEY is unset |
| OPENAI_MODEL | openai | — | Must be a multimodal/vision model. Used only if VISION_MODEL is unset |
| VISION_MAX_TOKENS | no | 1024 | Max output tokens per request |
| VISION_TIMEOUT_MS | no | 60000 | Request timeout in ms |
| VISION_MAX_RETRIES | no | 3 | Max retries for transient errors (429/timeout/5xx/network). Non-retryable errors (401/403/4xx) run exactly once |
| VISION_RETRY_BASE_DELAY_MS | no | 500 | First retry backoff (ms); exponential with jitter, capped at VISION_RETRY_MAX_DELAY_MS. Honors Retry-After |
| VISION_RETRY_MAX_DELAY_MS | no | 8000 | Cap on a single retry backoff |
| VISION_CACHE_DIR | no | (off) | Directory for content-addressed caching. Opt-in — caching is off unless this and a non-zero TTL are set |
| VISION_CACHE_TTL_SECONDS | no | 0 | Cache entry lifetime (seconds). 0 disables caching |
| VISION_CACHE_MAX_MB | no | 512 | Soft size cap; oldest entries evicted when exceeded |
| VISION_INCLUDE_METADATA | no | disabled | enabled | disabled — append a _meta block (model, timing, dimensions, cache hit, passes) to tool results. Informational only |
Why
VISION_*aliases? bun's.envloader does not override variables already present in the process environment. If a host already exportsANTHROPIC_*(e.g. a coding agent's own credentials, as Claude Code does), those inherited values silently shadow your config. TheVISION_*names never collide, so prefer them inmcpServersenvblocks and in.env.
From source (development)
For development, or if you prefer to run from a clone, bun runs the TypeScript directly with no build step. After cloning:
bun installThen configure your MCP client to run the server from source.
Run as an MCP server — z.ai GLM-4.6V (default)
{
"mcpServers": {
"vision": {
"command": "bun",
"args": ["run", "/ABS/PATH/vision-mcp/src/index.ts"],
"env": {
"VISION_PROVIDER": "openai",
"VISION_BASE_URL": "https://api.z.ai/api/coding/paas/v4",
"VISION_API_KEY": "your-z.ai-coding-plan-key",
"VISION_MODEL": "glm-4.6v",
"VISION_THINKING": "disabled",
"VISION_MAX_TOKENS": "1024",
"VISION_TIMEOUT_MS": "60000"
}
}
}
}Alternative: OpenCode Go / Anthropic
{
"mcpServers": {
"vision": {
"command": "bun",
"args": ["run", "/ABS/PATH/vision-mcp/src/index.ts"],
"env": {
"VISION_PROVIDER": "anthropic",
"VISION_BASE_URL": "https://opencode.ai/zen/go",
"VISION_API_KEY": "your-key",
"VISION_MODEL": "minimax-m3",
"VISION_MAX_TOKENS": "1024",
"VISION_TIMEOUT_MS": "60000"
}
}
}
}On OpenCode Go, MiniMax M3 / Qwen3.7 are served at
/v1/messages(Anthropic format,minimax-m3); GLM / Kimi / DeepSeek / MiMo are at/v1/chat/completions(OpenAI format). The Go coding models are code-optimized — verify a model is multimodal before relying on it for images.[1m]is not part of any model id — it's a CLI-only suffix and is rejected by the API. The free Zen tier (https://opencode.ai/zen/v1) routes differently from the Go subscription.
Alternative: OpenCode Go / GPT-5.6 Luna (Responses API)
GPT-5.6 Luna on Go is served only at /v1/responses (OpenAI Responses API format) — chat/completions returns 400 for it. Use VISION_PROVIDER=responses with a base URL that includes /v1:
{
"mcpServers": {
"vision": {
"command": "bun",
"args": ["run", "/ABS/PATH/vision-mcp/src/index.ts"],
"env": {
"VISION_PROVIDER": "responses",
"VISION_BASE_URL": "https://opencode.ai/zen/go/v1",
"VISION_API_KEY": "your-key",
"VISION_MODEL": "gpt-5.6-luna",
"VISION_THINKING": "disabled",
"VISION_MAX_TOKENS": "1024",
"VISION_TIMEOUT_MS": "60000"
}
}
}
}
VISION_THINKING=disabledmaps toreasoning: {effort: "none"}(fast/cheap perception & OCR);enabledomits the field so the model uses its default reasoning. Note the Responses API requiresinput_image.image_urlas a plain string data URI — the{url: …}object form is rejected with 400invalid_promptby strict upstreams.
Standalone live test
Dev / from-source utility — this script is run from a clone with bun.
scripts/test-vision.ts runs a single end-to-end call against your configured provider (bun auto-loads .env from the cwd). It sends your API key to your configured endpoint — that is the point.
# Uses an inline 1x1 PNG:
bun run scripts/test-vision.ts
# Or pass any image (path/URL/data-uri/base64):
bun run scripts/test-vision.ts ./photo.jpgTools
analyze_image— Describe or answer a question about a single image.image(string, required): file path, http(s) URL,data:URI, or base64prompt(string, optional): structured default (summary → subjects / quoted text / colors / layout)response_format("text"|"evidence", optional, defaulttext):evidenceseparates directly-visible facts from inferences and returns JSON{visible[], inferences[], unreadable[], confidence}(confidence = visual legibility, not diagnosis correctness)format("auto"|"png"|"jpeg"|"webp", optional):pngis lossless — best for text/diagramsmax_dimension(number, optional): downscale longest side ≤ N px (cost control)min_dimension(number, optional): upscale longest side ≥ N px (legibility for small images)include_metadata(boolean, optional): append a_metablock
inspect_image— Higher-level adaptive inspection: detects hard-to-read regions, zooms/upscales them, optionally OCRs critical text, and returns one consolidated answer with per-region evidence. Best for terminals, DevTools, dense diagrams, receipts, IDE screenshots. Bounded (≤ 3 passes, ≤ 4 regions); never recurses.image(string, required)question(string, optional): what to ask / find outmode("auto"|"fast"|"thorough", optional, defaultauto):fast=1 pass,auto≤≤2,thorough≤3 + OCR of critical regionsresponse_format("text"|"evidence", optional, defaulttext):evidencereturns the visible-vs-inference split as JSONformat,max_dimension,min_dimension,include_metadata(all optional)
compare_images— Compare 2–4 images (structured similarity/differences default).images(string[] or[{image, label?}], required, 2–4): each entry is an image, optionally with a label so the model never refers to "image 1/2" ambiguouslylabels(string[], optional): labels applied in order to plain-string imagesmode("semantic"|"ui-regression"|"pixel-layout"|"text-diff", optional, defaultsemantic):ui-regressionnormalizes both images to a common size and returns a structured JSON diff{missing, added, moved, textChanges, styleChanges, likelyAcceptable}prompt,include_metadata(optional)
extract_text— OCR a single image. Always re-encodes to lossless PNG first (JPEG artifacts wreck OCR); the prompt enforces reading order and exact quoting.image(string, required)output_format("plain" | "markdown" | "json" | "blocks", optional, defaultplain):blocksreturns spatial blocks{blocks:[{text, type, region}]}with best-effort normalized 0–1 regionsinclude_metadata(optional)
analyze_region— Zoom into part of an image for fine detail (small text, dense diagrams, tiny UI). Crops + upscales via sharp.image(string, required)region({x,y,width,height}in normalized 0–1, optional): zoom exactly this box. Omit for auto-detect of up to 4 regions of interest.max_passes(1–3, optional, default1): iteratively re-zoom a region until its detail is legible (bounded ≤ 3). Default1keeps the current single-pass behavior.prompt,zoom(1–8, default 2),format,max_dimension,include_metadata(all optional)
analyze_video— Summarize a video by sampling frames with ffmpeg and sending them to the model as images. Each frame carries its timestamp.video(string, required): file path, http(s) URL,data:URI, or base64frames(number, optional, 1–16, default 8): target frame countsampling("uniform"|"scene-change"|"hybrid", optional, defaultuniform):scene-changesamples at shot transitions (gt(scene,0.3));hybridmerges even + scene-change framesstart_seconds/end_seconds(number, optional): analyze only a window of the videoresponse_format("text"|"events", optional, defaulttext):eventsreturns JSON{events:[{timestamp, observation}], summary}prompt,include_metadata(optional)
Improving vision quality
These tools exist to make the model actually see better, not just forward pixels:
analyze_regionis the big one. The model normalizes images to ~1568px, so fine text that occupies a small fraction of a large image gets downscaled past readability and garbled (e.g.1Z→12,q3-vl→q3-v1,MZ9K→M29K). Cropping tightly around the text so it fills more of the frame restores legibility. Verified by A/B: a full-image read of a dense narrow-column receipt misread 5+ tokens; the zoomedanalyze_regionread them all correctly.inspect_imageautomates the zoom loop. Instead of manually chaininganalyze_image→analyze_region→extract_text,inspect_imagedoes a full-image pass, detects regions worth zooming, crops/upscales each, asks whether the detail is now legible, re-zooms if not (bounded ≤ 3 passes), and optionally OCRs critical text — returning one consolidated answer with per-region evidence and a visible-vs-inference split.extract_textforces lossless PNG, andanalyze_imageexposesformat/max_dimension/min_dimension, for the same reason — don't let JPEG artifacts or over-downscaling destroy text.
Reliability, caching & metadata
Every model request flows through a single pipeline that layers three behaviors (architecture invariants: only the pipeline retries or caches; tools and providers never do):
- Retries. Transient failures (
429, timeout,5xx, network) are retried with exponential backoff + jitter, honoring anyRetry-Afterheader (up toVISION_MAX_RETRIES). Non-retryable errors (401/403/other4xx) run exactly once. If the provider rejects an image format, the image is transcoded to PNG once and retried once before failing. - Caching (opt-in). Set
VISION_CACHE_DIR+ a non-zeroVISION_CACHE_TTL_SECONDSto memoize results in a content-addressed on-disk cache (keyed on image bytes + provider + model + prompt + options — never API keys). Preprocessed crops are cached separately too. This is valuable when an agent re-inspects the same screenshot while reasoning. Off by default; entries are TTL-expired and size-capped (VISION_CACHE_MAX_MB). - Metadata. Set
VISION_INCLUDE_METADATA=enabled(or passinclude_metadata: trueper call) to append a_metablock: model, duration, retry attempts, cache hit, image dimensions, and pass count._metais informational only — do not parse it in code.
Caveats
- Default provider is now
openai(z.ai). Previouslyanthropic— if you relied on the old default, setVISION_PROVIDER=anthropicexplicitly. - GLM-4.6V accepts jpg/png only (not webp/gif). The default tool paths emit PNG, so they're safe; an explicit
format: "webp"/"gif"onanalyze_image/analyze_regionis rejected by z.ai — but the pipeline now auto-transcodes to PNG once and retries on a format rejection, so such calls usually still succeed. VISION_THINKINGdefaults todisabledfor speed/cost on perception calls; setenabledfor harder reasoning.- Don't rely on
ANTHROPIC_*in a host that already exports them. bun's.envdoes not override inherited process env, so a coding agent's ownANTHROPIC_*will shadow your config. Use theVISION_*aliases in.envand inmcpServersenv. - API keys stay in
.env..envis gitignored — never commit it. - Video is frame-extraction, not native input. The provider endpoints accept only still images —
analyze_videosamples N evenly-spaced frames via ffmpeg and sends them as image blocks, so it captures motion but not audio or sub-frame detail; cost/latency scale with frame count (capped at 16). (z.ai also offers native video/file inputs we don't use — out of scope.)
Alternative-provider caveats (OpenCode Go / Anthropic)
These apply only when routing through OpenCode Go instead of the z.ai default:
[1m]is not part of the model id. opencode's CLI printsminimax-m3[1m]to denote a 1M-context variant, but the APImodelfield takes the bare idminimax-m3. Sending[1m]→Model … is not supported.- OpenCode Go vs. Zen routing. A Go subscription's base URL is always
https://opencode.ai/zen/go. The separate free Zen tier (https://opencode.ai/zen/v1) routes differently. minimax-m3is multimodal and verified working for image analysis on Go — and cheap ($0.30 in / $1.20 out per 1M tokens).
Verified
End-to-end green-path test against z.ai with glm-4.6v (OpenAI provider, config read straight from .env — only VISION_API_KEY required beyond baked defaults). Fetched a real image from a URL and the model read its text — confirming the full pipeline: baked defaults → image fetch → base64 → z.ai Bearer auth → OpenAI Chat Completions response parsing (effectively OCR-grade vision).
$ bun run scripts/test-vision.ts 'https://placehold.co/600x400/png'
Provider: openai | Model: glm-4.6v | Base URL: https://api.z.ai/api/coding/paas/v4
→ 'This is a tiny test image (placeholder image). It displays a plain light gray
background with the text "600 × 400" centered in a medium gray, sans-serif font…'Note: z.ai rejects the inline 1×1 transparent PNG used by the no-argument form of
scripts/test-vision.tswith HTTP 400 / error code 1210 ("image input format/parse error") — it dislikes degenerate/blank images, not the request shape. Pass a real image (URL/path) to verify the pipeline.
Reproduce with any image (local path, URL, data-URI, or base64):
bun run scripts/test-vision.ts ./photo.jpg