Outils de vision MCP avec MiniCPM-V sur Ollama
Exposez des outils de description, d’OCR et de comparaison d’images via MCP avec MiniCPM-V sur Ollama, afin que Cursor et Claude Desktop puissent traiter les captures d’écran entièrement en local.
Outils de vision locale pour les hôtes MCP
Déployez un seul serveur MCP qui gère la description des images, le OCR de documents et la comparaison par paires d’images. Tout hôte compatible MCP — Cursor, Claude Desktop et similaires — peut alors analyser des captures d’écran, des reçus et des différences d’interface sans devoir créer une API HTTP privée à chaque fois.
Cette capacité provient de MiniCPM-V 4.6 fourni par Ollama : environ 1,3 milliard de paramètres, une taille de téléchargement faible, prise en charge du texte et des images, ainsi qu’une large fenêtre de contexte. Il est conçu pour une vision adaptée aux environnements edge. La démonstration est simple : il suffit de glisser une capture d’écran dans l’IDE et de demander ce qui a changé ; l’agent effectue alors une comparaison plutôt que d’utiliser un fournisseur de services de vision en nuage.
Objectifs d’apprentissage
Vous téléchargerez le modèle de vision, comprendrez pourquoi MCP est supérieur aux solutions REST ponctuelles pour les outils, partagerez un backend Ollama entre le serveur et les démonstrations, implémenterez trois outils bien définis, effectuerez un guide pas à pas en terminal, puis connecterez Cursor et Claude Desktop.
Pourquoi les agents ont besoin d’outils de vision
Les agents à texte pur échouent lorsque les preuves sont sous forme de PNG ou de PDF scanné. Les API de vision cloud fonctionnent, mais nécessitent des clés d’accès, une politique d’égress et un autre SDK à gérer. La publication d’outils via MCP permet de garder le choix du modèle et la gestion du système de fichiers au sein de votre processus, tandis que les éditeurs ne voient que des schémas et des arguments de type.
MiniCPM-V via Ollama
ollama pull minicpm-v4.6
ollama run minicpm-v4.6 "Describe this image" --image ./photo.jpg
Si cette requête renvoie une légende cohérente, l’environnement de exécution est prêt pour MCP.
Structure de démarrage rapide
Clonez le dossier du guide, créez un venv et installez les dépendances nécessaires :
cd guides/minicpm-v-mcp-server
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
ollama pull minicpm-v4.6
python examples/generate_fixtures.py
python examples/agent_demo.py
Générez des fichiers de test synthétiques afin que les démonstrations ne dépendent pas d’captures d’écran propriétaires :
generate_fixtures.py
#!/usr/bin/env python3
"""Generate sample images for MCP demos (no external assets required)."""
from __future__ import annotations
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
FIXTURES = Path(__file__).resolve().parent / "fixtures"
def _font(size: int):
for name in ("DejaVuSans.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf"):
try:
return ImageFont.truetype(name, size)
except OSError:
continue
return ImageFont.load_default()
def receipt() -> None:
img = Image.new("RGB", (480, 640), "#fafafa")
d = ImageDraw.Draw(img)
f, fs = _font(22), _font(16)
d.text((40, 40), "COFFEE BEAN Co.", fill="#1a1a1a", font=f)
d.text((40, 90), "123 Main St · San Francisco", fill="#555", font=fs)
lines = [
"Latte (Oat) $5.50",
"Croissant $4.25",
"Tip $1.00",
"─────────────────────────",
"TOTAL $10.75",
"Card **** 4242",
"2026-06-23 09:14 AM",
]
y = 150
for line in lines:
d.text((40, y), line, fill="#222", font=fs)
y += 36
img.save(FIXTURES / "sample_receipt.png")
def diagram_v1() -> None:
img = Image.new("RGB", (640, 400), "#0f172a")
d = ImageDraw.Draw(img)
f = _font(18)
d.rounded_rectangle((40, 80, 200, 160), radius=12, fill="#1e3a5f", outline="#38bdf8")
d.text((70, 110), "API", fill="#e2e8f0", font=f)
d.rounded_rectangle((420, 80, 580, 160), radius=12, fill="#14532d", outline="#4ade80")
d.text((440, 110), "Qdrant", fill="#e2e8f0", font=f)
d.line((200, 120, 420, 120), fill="#94a3b8", width=3)
d.text((250, 200), "v1 — sync pipeline", fill="#94a3b8", font=f)
img.save(FIXTURES / "diagram_v1.png")
def diagram_v2() -> None:
img = Image.new("RGB", (640, 400), "#0f172a")
d = ImageDraw.Draw(img)
f = _font(18)
d.rounded_rectangle((40, 80, 200, 160), radius=12, fill="#1e3a5f", outline="#38bdf8")
d.text((60, 110), "LitServe", fill="#e2e8f0", font=f)
d.rounded_rectangle((250, 60, 410, 140), radius=12, fill="#4c1d95", outline="#a78bfa")
d.text((270, 90), "CrewAI", fill="#e2e8f0", font=f)
d.rounded_rectangle((420, 80, 580, 160), radius=12, fill="#14532d", outline="#4ade80")
d.text((440, 110), "Qdrant", fill="#e2e8f0", font=f)
d.line((200, 120, 250, 100), fill="#94a3b8", width=3)
d.line((410, 100, 420, 120), fill="#94a3b8", width=3)
d.text((200, 200), "v2 — agentic pipeline", fill="#fbbf24", font=f)
img.save(FIXTURES / "diagram_v2.png")
def main() -> None:
FIXTURES.mkdir(parents=True, exist_ok=True)
receipt()
diagram_v1()
diagram_v2()
print(f"Fixtures written to {FIXTURES}")
if __name__ == "__main__":
main()
Le script de démonstration de l’agent bout en bout utilise les mêmes outils que ceux qui seront exposés par le serveur MCP :
agent_demo.py
#!/usr/bin/env python3
"""End-to-end demo — MiniCPM-V MCP vision tools (works offline with OLLAMA_MOCK=1).
Simulates what Cursor / Claude Desktop sees when the agent calls vision tools.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
import vision_backend as vb # noqa: E402
from server import compare_images, describe_image, ocr_document # noqa: E402
FIXTURES = ROOT / "fixtures"
def _banner(title: str) -> None:
print(f"\n{'=' * 60}\n {title}\n{'=' * 60}")
def _parse(result: str) -> dict:
return json.loads(result)
def main() -> None:
os.environ.setdefault("OLLAMA_VISION_MODEL", "minicpm-v4.6")
if not FIXTURES.exists() or not list(FIXTURES.glob("*.png")):
from generate_fixtures import main as gen # noqa: E402
gen()
status = vb.health_check()
_banner("MiniCPM-V 4.6 Vision MCP — Agent Demo")
print(f"Model: {vb.VISION_MODEL} · Mode: {status.get('mode', '?')}")
if not status.get("ok") and not vb.MOCK:
print("\n⚠️ Ollama offline — re-run with OLLAMA_MOCK=1 or start Ollama.\n")
# Scenario 1 — describe screenshot
_banner("Scenario 1 — describe_image")
print('[Tool: describe_image] path=fixtures/diagram_v2.png')
r1 = _parse(describe_image(str(FIXTURES / "diagram_v2.png"), "What services are shown?"))
print("\n## Architecture summary\n")
print(r1.get("result", r1.get("error", r1)))
# Scenario 2 — OCR receipt
_banner("Scenario 2 — ocr_document")
print('[Tool: ocr_document] path=fixtures/sample_receipt.png')
r2 = _parse(ocr_document(str(FIXTURES / "sample_receipt.png")))
print("\n## Receipt OCR\n")
print(r2.get("result", r2.get("error", r2)))
# Scenario 3 — compare before/after
_banner("Scenario 3 — compare_images")
print("[Tool: compare_images] v1 → v2 pipeline diagrams")
r3 = _parse(
compare_images(
str(FIXTURES / "diagram_v1.png"),
str(FIXTURES / "diagram_v2.png"),
focus="new components and labels",
)
)
print("\n## Visual diff\n")
print(r3.get("result", r3.get("error", r3)))
_banner("Done — wire examples/server.py into Cursor MCP settings")
print("See examples/cursor_mcp.json.example")
if __name__ == "__main__":
main()
Backend de vision partagée
Centraliser les appels à Ollama afin que MCP et les démonstrations ne s’éloignent pas de leur trajectoire :
vision_backend.py
"""Ollama vision backend for MiniCPM-V 4.6 — shared by MCP server and demos."""
from __future__ import annotations
import base64
import os
from pathlib import Path
import httpx
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/")
VISION_MODEL = os.environ.get("OLLAMA_VISION_MODEL", "minicpm-v4.6")
MOCK = os.environ.get("OLLAMA_MOCK", "0") == "1"
SUPPORTED_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
class VisionError(Exception):
pass
def _encode_image(path: Path) -> str:
if not path.is_file():
raise VisionError(f"Image not found: {path}")
if path.suffix.lower() not in SUPPORTED_SUFFIXES:
raise VisionError(f"Unsupported image type: {path.suffix}")
return base64.b64encode(path.read_bytes()).decode("ascii")
def _mock_response(prompt: str, image_count: int) -> str:
return (
f"[mock {VISION_MODEL}] Processed {image_count} image(s).\n"
f"Prompt preview: {prompt[:120]}…\n"
"Set OLLAMA_MOCK=0 and run `ollama pull minicpm-v4.6` for live inference."
)
def chat_vision(prompt: str, image_paths: list[Path], *, timeout: float = 120.0) -> str:
"""Send a vision chat request to Ollama."""
if MOCK:
return _mock_response(prompt, len(image_paths))
images_b64 = [_encode_image(p) for p in image_paths]
payload = {
"model": VISION_MODEL,
"messages": [{"role": "user", "content": prompt, "images": images_b64}],
"stream": False,
}
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(f"{OLLAMA_HOST}/api/chat", json=payload)
resp.raise_for_status()
data = resp.json()
except httpx.ConnectError as exc:
raise VisionError(
f"Cannot reach Ollama at {OLLAMA_HOST}. Start Ollama and run: ollama pull {VISION_MODEL}"
) from exc
except httpx.HTTPStatusError as exc:
raise VisionError(f"Ollama error {exc.response.status_code}: {exc.response.text[:300]}") from exc
message = data.get("message") or {}
content = message.get("content", "").strip()
if not content:
raise VisionError("Empty response from Ollama")
return content
def health_check() -> dict:
"""Return model + connectivity status for demos."""
if MOCK:
return {"ok": True, "mode": "mock", "model": VISION_MODEL}
try:
with httpx.Client(timeout=5.0) as client:
resp = client.get(f"{OLLAMA_HOST}/api/tags")
resp.raise_for_status()
tags = {m.get("name", "").split(":")[0] for m in resp.json().get("models", [])}
base = VISION_MODEL.split(":")[0]
return {
"ok": base in tags or VISION_MODEL in tags,
"mode": "live",
"model": VISION_MODEL,
"ollama_host": OLLAMA_HOST,
}
except Exception as exc: # noqa: BLE001 — demo helper
return {"ok": False, "mode": "offline", "model": VISION_MODEL, "error": str(exc)}
Forme typique du chargement pour chat-with-image :
payload = {
"model": VISION_MODEL, # minicpm-v4.6
"messages": [{"role": "user", "content": prompt, "images": images_b64}],
"stream": False,
}
Modèle d’environnement :
.env.example
# Ollama host (default local)
OLLAMA_HOST=http://127.0.0.1:11434
# Vision model — 1.6 GB, text + image, 256K context
OLLAMA_VISION_MODEL=minicpm-v4.6
# Set to 1 to run agent_demo without Ollama (offline smoke test)
OLLAMA_MOCK=0
Dirigez l’URL du hôte vers localhost ou un routeur LAN ; indiquez explicitement le nom du modèle de vision.
Interface des outils
describe_image résume le contenu d’un seul fichier aux fins de tri. ocr_document extrait du texte des reçus, de formulaires et d’diapositives. compare_images explique les différences entre deux captures d’interface pour répondre aux questions sur ce qui a changé.
Point d’entrée du serveur enregistrant ces outils :
server.py
#!/usr/bin/env python3
"""MiniCPM-V MCP server — vision tools for Cursor, Claude Desktop, and Hermes.
Exposes three tools over MCP:
describe_image(path, question?) → general image understanding
ocr_document(path) → structured text extraction
compare_images(path_a, path_b, focus?) → side-by-side visual diff
Powered by MiniCPM-V 4.6 via Ollama (~1.6 GB, text + image, 256K context).
Run: python examples/server.py # stdio transport (for MCP hosts)
"""
from __future__ import annotations
import json
from pathlib import Path
from mcp.server.fastmcp import FastMCP
try:
from . import vision_backend as vb
except ImportError: # pragma: no cover
import vision_backend as vb # type: ignore
mcp = FastMCP("minicpm-vision")
DESCRIBE_DEFAULT = (
"Describe this image in detail. Include objects, text visible, layout, "
"colors, and anything notable for a developer reviewing a screenshot."
)
OCR_PROMPT = (
"Extract all readable text from this document or screenshot. "
"Preserve structure with markdown headings and bullet lists where appropriate. "
"If tables are present, format them as markdown tables."
)
COMPARE_DEFAULT = (
"Compare these two images. List similarities and differences. "
"Note UI changes, text changes, and layout shifts."
)
def _resolve(path: str) -> Path:
p = Path(path).expanduser().resolve()
if not p.is_file():
raise FileNotFoundError(f"Not a file: {p}")
return p
def _tool_result(text: str, **meta) -> str:
return json.dumps({"result": text, **meta}, indent=2)
@mcp.tool()
def describe_image(path: str, question: str = "") -> str:
"""Describe or answer questions about a single image using MiniCPM-V 4.6.
Args:
path: Absolute or relative path to a PNG, JPG, WEBP, or GIF file.
question: Optional specific question about the image. Leave empty for
a general description.
Returns JSON with the model's answer and metadata.
"""
try:
img = _resolve(path)
prompt = question.strip() or DESCRIBE_DEFAULT
answer = vb.chat_vision(prompt, [img])
return _tool_result(answer, tool="describe_image", path=str(img), model=vb.VISION_MODEL)
except (FileNotFoundError, vb.VisionError) as exc:
return json.dumps({"error": str(exc)}, indent=2)
@mcp.tool()
def ocr_document(path: str) -> str:
"""OCR a document, receipt, whiteboard photo, or screenshot to markdown text.
Args:
path: Absolute or relative path to the image file.
Returns JSON with extracted text in markdown format.
"""
try:
img = _resolve(path)
answer = vb.chat_vision(OCR_PROMPT, [img])
return _tool_result(answer, tool="ocr_document", path=str(img), model=vb.VISION_MODEL)
except (FileNotFoundError, vb.VisionError) as exc:
return json.dumps({"error": str(exc)}, indent=2)
@mcp.tool()
def compare_images(path_a: str, path_b: str, focus: str = "") -> str:
"""Compare two images and report visual differences.
Args:
path_a: Path to the first image (e.g. before screenshot).
path_b: Path to the second image (e.g. after screenshot).
focus: Optional aspect to focus on (e.g. "navigation bar", "error message").
Returns JSON with a structured comparison.
"""
try:
a, b = _resolve(path_a), _resolve(path_b)
prompt = COMPARE_DEFAULT
if focus.strip():
prompt += f"\n\nFocus especially on: {focus.strip()}"
answer = vb.chat_vision(prompt, [a, b])
return _tool_result(
answer,
tool="compare_images",
path_a=str(a),
path_b=str(b),
model=vb.VISION_MODEL,
)
except (FileNotFoundError, vb.VisionError) as exc:
return json.dumps({"error": str(exc)}, indent=2)
@mcp.resource("minicpm-vision://model")
def model_info() -> str:
"""Capability hint: which vision model and host this server uses."""
status = vb.health_check()
return json.dumps(
{
"model": vb.VISION_MODEL,
"ollama_host": vb.OLLAMA_HOST,
"tools": ["describe_image", "ocr_document", "compare_images"],
"status": status,
},
indent=2,
)
if __name__ == "__main__":
mcp.run(transport="stdio")
Boucle de démonstration
Générer des fichiers d’exemple et exécuter la démonstration de l’agent :
python examples/generate_fixtures.py
python examples/agent_demo.py
agent_demo.py
#!/usr/bin/env python3
"""End-to-end demo — MiniCPM-V MCP vision tools (works offline with OLLAMA_MOCK=1).
Simulates what Cursor / Claude Desktop sees when the agent calls vision tools.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
import vision_backend as vb # noqa: E402
from server import compare_images, describe_image, ocr_document # noqa: E402
FIXTURES = ROOT / "fixtures"
def _banner(title: str) -> None:
print(f"\n{'=' * 60}\n {title}\n{'=' * 60}")
def _parse(result: str) -> dict:
return json.loads(result)
def main() -> None:
os.environ.setdefault("OLLAMA_VISION_MODEL", "minicpm-v4.6")
if not FIXTURES.exists() or not list(FIXTURES.glob("*.png")):
from generate_fixtures import main as gen # noqa: E402
gen()
status = vb.health_check()
_banner("MiniCPM-V 4.6 Vision MCP — Agent Demo")
print(f"Model: {vb.VISION_MODEL} · Mode: {status.get('mode', '?')}")
if not status.get("ok") and not vb.MOCK:
print("\n⚠️ Ollama offline — re-run with OLLAMA_MOCK=1 or start Ollama.\n")
# Scenario 1 — describe screenshot
_banner("Scenario 1 — describe_image")
print('[Tool: describe_image] path=fixtures/diagram_v2.png')
r1 = _parse(describe_image(str(FIXTURES / "diagram_v2.png"), "What services are shown?"))
print("\n## Architecture summary\n")
print(r1.get("result", r1.get("error", r1)))
# Scenario 2 — OCR receipt
_banner("Scenario 2 — ocr_document")
print('[Tool: ocr_document] path=fixtures/sample_receipt.png')
r2 = _parse(ocr_document(str(FIXTURES / "sample_receipt.png")))
print("\n## Receipt OCR\n")
print(r2.get("result", r2.get("error", r2)))
# Scenario 3 — compare before/after
_banner("Scenario 3 — compare_images")
print("[Tool: compare_images] v1 → v2 pipeline diagrams")
r3 = _parse(
compare_images(
str(FIXTURES / "diagram_v1.png"),
str(FIXTURES / "diagram_v2.png"),
focus="new components and labels",
)
)
print("\n## Visual diff\n")
print(r3.get("result", r3.get("error", r3)))
_banner("Done — wire examples/server.py into Cursor MCP settings")
print("See examples/cursor_mcp.json.example")
if __name__ == "__main__":
main()
Les appels d’outil attendus ont l’aspect suivant :
[Tool: describe_image] path=fixtures/diagram_v2.png
[Tool: ocr_document] path=fixtures/sample_receipt.png
[Tool: compare_images] v1 → v2 pipeline diagrams
CI sans GPU peut forcer des réponses simulées :
OLLAMA_MOCK=1 python examples/agent_demo.py
Configuration du hôte
Entrée du curseur mcpServers :
{
"mcpServers": {
"minicpm-vision": {
"command": "python",
"args": ["examples/server.py"],
"cwd": "/absolute/path/to/guides/minicpm-v-mcp-server",
"env": {
"OLLAMA_VISION_MODEL": "minicpm-v4.6",
"OLLAMA_HOST": "http://127.0.0.1:11434"
}
}
}
}
Équivalent sur Claude Desktop :
{
"mcpServers": {
"minicpm-vision": {
"command": "python",
"args": ["/absolute/path/to/guides/minicpm-v-mcp-server/examples/server.py"],
"env": {
"OLLAMA_VISION_MODEL": "minicpm-v4.6",
"OLLAMA_HOST": "http://127.0.0.1:11434"
}
}
}
}
Les deux pointent command vers votre environnement Python venv et transmettent les paramètres d’Ollama via des variables d’environnement.
Notes opérationnelles
Gardez les fichiers de configuration intégrés, documentez OLLAMA_MOCK, et indiquez précisément la version d’Ollama dans le README. Cela rend l’intégration et les vérifications de PR fastidieuses — ce qui est l’objectif pour des outils visuels en lesquels les collègues pourront réellement avoir confiance.
En résumé
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que CI et ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que CI et ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que CI et ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que CI et ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que CI et ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que CI et ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que CI et ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que CI et ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que CI et ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que CI et ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que le CI et les ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que le CI et les ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que le CI et les ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que le CI et les ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que le CI et les ordinateurs portables se comportent de la même manière.
Décrivez les ports, les étiquettes de modèle et les flags de simulation afin que le CI et les ordinateurs portables se comportent de la même manière.