Files
subochev 585c6a4ca4 routerai-image-mcp: MCP-сервер генерации картинок через RouterAI
- server.py: MCP-сервер (stdio + streamable HTTP), инструменты generate_image / list_image_models
- два бэкенда: chat/completions (Gemini Image, понимает правку по референсу) и images/generations (FLUX, GPT-Image)
- ключ RouterAI берётся из env или ~/.hermes/models.yaml автоматически
- retry на 429/5xx, картинка сохраняется файлом (~/.hermes/image_cache/mcp-gen)
- test_client.py / test_edit.py: проверка по протоколу, включая image-to-image
- README: установка, настройка, подключение к Hermes/opencode, питфоллы
2026-09-24 11:01:23 +03:00

290 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""RouterAI Image MCP server.
Даёт ЛЮБОЙ модели (в т.ч. той, что не умеет рисовать) инструмент
`generate_image` — генерация/редактирование картинок через RouterAI
(Nano Banana / Gemini Image, FLUX, GPT-Image).
Транспорт:
MCP_TRANSPORT=stdio (по умолчанию) | http
MCP_PORT=8790 (для http)
Ключ ищем в порядке:
1) env ROUTERAI_API_KEY / IMAGE_MCP_API_KEY
2) ~/.hermes/models.yaml -> models.deepseek.api_key (base_url = routerai.ru)
3) ~/.hermes/models.yaml -> любой api_key с base_url routerai.ru
"""
from __future__ import annotations
import base64
import json
import os
import re
import sys
import time
import mimetypes
import urllib.error
import urllib.request
from pathlib import Path
from mcp.server.fastmcp import FastMCP
BASE_URL = os.environ.get("ROUTERAI_BASE_URL", "https://routerai.ru/api/v1").rstrip("/")
DEFAULT_MODEL = os.environ.get("IMAGE_MCP_DEFAULT_MODEL", "google/gemini-3.1-flash-image")
DEFAULT_OUT_DIR = os.environ.get(
"IMAGE_MCP_OUT_DIR", str(Path.home() / ".hermes" / "image_cache" / "mcp-gen")
)
REQUEST_TIMEOUT = int(os.environ.get("IMAGE_MCP_TIMEOUT", "300"))
# модель -> (бэкенд, человеческое имя). backend "chat" = OpenAI chat с modalities,
# backend "images" = POST /images/generations
MODELS: dict[str, tuple[str, str]] = {
"google/gemini-3.1-flash-image": ("chat", "Nano Banana 2 (Gemini 3.1 Flash Image)"),
"google/gemini-3.1-flash-lite-image": ("chat", "Nano Banana 2 Lite"),
"google/gemini-2.5-flash-image": ("chat", "Nano Banana (Gemini 2.5 Flash Image)"),
"google/gemini-3-pro-image": ("chat", "Nano Banana Pro (Gemini 3 Pro Image)"),
"google/gemini-3.1-flash-image-preview": ("chat", "Nano Banana 2 (preview)"),
"google/gemini-3-pro-image-preview": ("chat", "Nano Banana Pro (preview)"),
"openai/gpt-image-1": ("images", "GPT Image 1"),
"openai/gpt-image-1-mini": ("images", "GPT Image 1 Mini"),
"black-forest-labs/flux.2-pro": ("images", "FLUX.2 Pro"),
"black-forest-labs/flux.2-max": ("images", "FLUX.2 Max"),
"black-forest-labs/flux.2-flex": ("images", "FLUX.2 Flex"),
"black-forest-labs/flux.2-klein-4b": ("images", "FLUX.2 Klein 4B"),
}
mcp = FastMCP("routerai-image")
# --------------------------------------------------------------------------- key
def _api_key() -> str:
for env in ("ROUTERAI_API_KEY", "IMAGE_MCP_API_KEY"):
v = os.environ.get(env)
if v and len(v) > 20:
return v.strip()
models_yaml = Path.home() / ".hermes" / "models.yaml"
if models_yaml.exists():
try:
import yaml # type: ignore
data = yaml.safe_load(models_yaml.read_text(encoding="utf-8")) or {}
cands = []
for name, entry in (data.get("models") or {}).items():
if not isinstance(entry, dict):
continue
key = str(entry.get("api_key") or "")
base = str(entry.get("base_url") or "")
if "routerai.ru" in base and key.startswith("sk-"):
cands.append((name != "deepseek", key))
if cands:
cands.sort()
return cands[0][1]
except Exception as exc: # noqa: BLE001
print(f"[routerai-image] models.yaml parse failed: {exc}", file=sys.stderr)
raise RuntimeError(
"RouterAI API key not found. Set ROUTERAI_API_KEY, or put a routerai.ru "
"entry with api_key into ~/.hermes/models.yaml"
)
# ---------------------------------------------------------------------------- http
def _post(path: str, body: dict, timeout: int = REQUEST_TIMEOUT) -> dict:
key = _api_key()
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
last_err = ""
for attempt in range(4):
req = urllib.request.Request(
f"{BASE_URL}{path}",
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {key}",
"User-Agent": "routerai-image-mcp/1.0",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
# RouterAI иногда отдаёт мусорные префиксы перед JSON
start = raw.find(b"{")
return json.loads(raw[start:] if start > 0 else raw)
except urllib.error.HTTPError as exc:
payload = exc.read().decode("utf-8", "replace")[:400]
last_err = f"HTTP {exc.code}: {payload}"
if exc.code in (429, 500, 502, 503, 504) and attempt < 3:
time.sleep(3 * (attempt + 1))
continue
raise RuntimeError(last_err) from None
except Exception as exc: # noqa: BLE001
last_err = f"{type(exc).__name__}: {exc}"
if attempt < 3:
time.sleep(3 * (attempt + 1))
continue
raise RuntimeError(last_err) from None
raise RuntimeError(last_err)
# ------------------------------------------------------------------------- helpers
def _data_url(path: str) -> str:
p = Path(path).expanduser()
if not p.exists():
raise RuntimeError(f"reference image not found: {p}")
mime = mimetypes.guess_type(p.name)[0] or "image/png"
return f"data:{mime};base64,{base64.b64encode(p.read_bytes()).decode()}"
def _decode_image(item: dict) -> bytes:
"""Принимает {'b64_json': ...} или {'url': 'data:...'} / http url."""
if item.get("b64_json"):
return base64.b64decode(item["b64_json"])
url = item.get("url") or (item.get("image_url") or {}).get("url") or ""
if url.startswith("data:"):
return base64.b64decode(url.split(",", 1)[1])
if url.startswith("http"):
with urllib.request.urlopen(url, timeout=REQUEST_TIMEOUT) as resp: # noqa: S310
return resp.read()
raise RuntimeError("no image payload in response")
def _ext_from_bytes(blob: bytes) -> str:
if blob[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if blob[:3] == b"\xff\xd8\xff":
return ".jpg"
if blob[:4] == b"RIFF" and blob[8:12] == b"WEBP":
return ".webp"
return ".png"
def _slug(text: str, limit: int = 40) -> str:
txt = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE).strip()
txt = re.sub(r"[\s-]+", "-", txt)
return (txt[:limit] or "image").strip("-").lower()
def _run(model: str, prompt: str, refs: list[str] | None, out_dir: str, filename: str | None) -> str:
if model not in MODELS:
known = ", ".join(sorted(MODELS))
raise RuntimeError(f"unknown model '{model}'. Known: {known}")
backend, human = MODELS[model]
refs = refs or []
started = time.time()
if backend == "chat":
if refs:
content: list[dict] = [{"type": "text", "text": prompt}]
content += [
{"type": "image_url", "image_url": {"url": _data_url(p)}} for p in refs
]
else:
content = prompt # type: ignore[assignment]
body = {
"model": model,
"messages": [{"role": "user", "content": content}],
"modalities": ["image", "text"],
}
if not refs:
body["response_modalities"] = ["IMAGE"]
data = _post("/chat/completions", body)
msg = (data.get("choices") or [{}])[0].get("message", {}) or {}
images = msg.get("images") or []
if not images and isinstance(msg.get("content"), list):
images = [c for c in msg["content"] if c.get("type") in ("image_url", "image")]
if not images:
raise RuntimeError(f"model returned no image. raw: {json.dumps(data)[:400]}")
blob = _decode_image(images[0])
usage = data.get("usage") or {}
else:
body = {"model": model, "prompt": prompt, "n": 1}
if refs:
body["image"] = _data_url(refs[0])
data = _post("/images/generations", body)
items = data.get("data") or []
if not items:
raise RuntimeError(f"model returned no image. raw: {json.dumps(data)[:400]}")
blob = _decode_image(items[0])
usage = data.get("usage") or {}
out = Path(out_dir).expanduser()
out.mkdir(parents=True, exist_ok=True)
name = filename or f"{time.strftime('%Y%m%d-%H%M%S')}-{_slug(prompt)}"
if not Path(name).suffix:
name += _ext_from_bytes(blob)
dest = out / name
dest.write_bytes(blob)
cost = usage.get("cost")
secs = time.time() - started
cost_line = f"{cost:.2f}₽" if isinstance(cost, (int, float)) else "n/a"
kb = len(blob) / 1024
return (
f"Готово: {human}\n"
f"Файл: {dest} ({kb:.0f} KB)\n"
f"Модель: {model} | время: {secs:.1f}s | стоимость: {cost_line}\n"
f"MEDIA:{dest}\n"
f"(покажи файл пользователю: вставь строку MEDIA:<путь> целиком в ответ)"
)
# --------------------------------------------------------------------------- tools
@mcp.tool()
def generate_image(
prompt: str,
model: str = DEFAULT_MODEL,
reference_images: list[str] | None = None,
out_dir: str = DEFAULT_OUT_DIR,
filename: str | None = None,
) -> str:
"""Сгенерировать картинку по текстовому ТЗ (или отредактировать существующую).
Args:
prompt: ТЗ на картинке — что нарисовать. Чем конкретнее (сюжет, стиль,
свет, композиция, пропорции), тем лучше результат.
model: id модели RouterAI. Дефолт google/gemini-3.1-flash-image
(Nano Banana 2). Дешёвые: google/gemini-3.1-flash-lite-image,
google/gemini-2.5-flash-image. Дорогие: google/gemini-3-pro-image.
Альтернативы: black-forest-labs/flux.2-pro, openai/gpt-image-1.
reference_images: список путей к картинкам-референсам/для правки.
out_dir: куда сложить результат (по умолчанию ~/.hermes/image_cache/mcp-gen).
filename: имя файла без пути (расширение подставится само).
Returns:
Текст с абсолютным путём к файлу и строкой `MEDIA:<путь>` — вставь эту
строку в свой ответ, чтобы клиент показал картинку.
"""
return _run(model, prompt, reference_images, out_dir, filename)
@mcp.tool()
def list_image_models() -> str:
"""Список доступных моделей генерации картинок и их бэкендов."""
lines = [f"{mid} — {human} (backend: {backend})" for mid, (backend, human) in sorted(MODELS.items())]
return "Доступные модели:\n" + "\n".join(lines) + f"\n\nДефолт: {DEFAULT_MODEL}\nБазовый URL: {BASE_URL}"
def main() -> None:
transport = os.environ.get("MCP_TRANSPORT", "stdio").lower()
if transport in ("http", "streamable-http", "sse"):
mcp.settings.host = os.environ.get("MCP_HOST", "0.0.0.0")
mcp.settings.port = int(os.environ.get("MCP_PORT", "8790"))
mcp.run(transport="streamable-http")
else:
mcp.run(transport="stdio")
if __name__ == "__main__":
main()