commit 585c6a4ca42d3cfbc8e41cec7c4d96ce3197c340 Author: Hermes Agent Date: Thu Sep 24 11:01:23 2026 +0300 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, питфоллы diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8af99ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.venv/ +*.png +*.jpg +*.jpeg +*.webp +.env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea51d4a --- /dev/null +++ b/README.md @@ -0,0 +1,222 @@ +# routerai-image-mcp + +MCP-сервер генерации и редактирования картинок через **RouterAI** +(`https://routerai.ru/api/v1`). Даёт любой модели — в том числе той, что +рисовать не умеет, но умеет ставить ТЗ другим — инструмент `generate_image`: +передал текстовое описание, получил файл с картинкой. + +## Зачем + +Агентные модели (DeepSeek, Qwen, локальные) отлично формулируют ТЗ, но не +генерируют изображения. Этот сервер закрывает разрыв: модель вызывает +`generate_image(prompt=...)`, сервер ходит в модель генерации картинок +(Nano Banana / Gemini Image, FLUX, GPT-Image), кладёт результат файлом и +возвращает путь. Модель вставляет этот путь в ответ — клиент показывает картинку. + +## Архитектура + +``` +LLM (agent) ──MCP──▶ routerai-image-mcp ──HTTPS──▶ routerai.ru/api/v1 + │ + └─▶ файл на диске (~/.hermes/image_cache/mcp-gen/) + возвращает: "MEDIA:<путь>" +``` + +Два бэкенда, выбираются автоматически по id модели: + +| Backend | Что вызывается | Модели | +|---|---|---| +| `chat` | `POST /chat/completions` с `modalities: ["image","text"]` | Gemini Image / Nano Banana | +| `images` | `POST /images/generations` | FLUX.2, GPT-Image | + +`chat`-бэкенд дополнительно умеет **image-to-image**: если передать +`reference_images`, они уходят в запрос как image_url-части, и модель +редактирует присланную картинку (сохранив сюжет) — проверено на «поменяй фон». + +## Требования + +- Python 3.10+ +- Пакет `mcp` (`pip install mcp`) — в Hermes он уже есть: + `/usr/local/lib/hermes-agent/venv/bin/python` +- Ключ RouterAI (см. «Настройка») +- Сетевой доступ к `https://routerai.ru` + +## Установка + +```bash +git clone https://git.binom.pw/subochev/routerai-image-mcp +cd routerai-image-mcp + +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +``` + +Либо без своего venv — использовать интерпретатор Hermes (там `mcp` уже стоит): + +```bash +/usr/local/lib/hermes-agent/venv/bin/python server.py +``` + +## Настройка + +Ключ ищется в таком порядке: + +1. `ROUTERAI_API_KEY` (или `IMAGE_MCP_API_KEY`) в окружении; +2. `~/.hermes/models.yaml` → `models.deepseek.api_key` (если у записи + `base_url` содержит `routerai.ru`); +3. любой подходящий `api_key` из `models.yaml`, где `base_url` = routerai.ru. + +То есть на машине с Hermes, где `models.yaml` уже настроен, **ничего задавать +не нужно** — ключ подхватится сам. Явный способ: + +```bash +export ROUTERAI_API_KEY='sk-...' +``` + +Переменные окружения сервера: + +| Переменная | Дефолт | Смысл | +|---|---|---| +| `ROUTERAI_API_KEY` | — | Ключ RouterAI (иначе берётся из `models.yaml`) | +| `ROUTERAI_BASE_URL` | `https://routerai.ru/api/v1` | Базовый URL API | +| `IMAGE_MCP_DEFAULT_MODEL` | `google/gemini-3.1-flash-image` | Модель по умолчанию | +| `IMAGE_MCP_OUT_DIR` | `~/.hermes/image_cache/mcp-gen` | Куда складывать картинки | +| `IMAGE_MCP_TIMEOUT` | `300` | Таймаут запроса, сек | +| `MCP_TRANSPORT` | `stdio` | `stdio` или `http` | +| `MCP_HOST` / `MCP_PORT` | `0.0.0.0` / `8790` | Только для `http` | + +## Запуск + +### stdio (обычный режим для локальных клиентов) + +```bash +python server.py +``` + +### HTTP (когда сервер нужен по сети нескольким клиентам) + +```bash +MCP_TRANSPORT=http MCP_PORT=8790 python server.py +``` + +Эндпоинт: `http://:8790/mcp` (streamable HTTP). Проверка: + +```bash +curl -s -X POST http://127.0.0.1:8790/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' +# → event: message / data: {... "serverInfo":{"name":"routerai-image"}} +``` + +## Подключение к Hermes + +```bash +hermes mcp add routerai-image \ + --command /usr/local/lib/hermes-agent/venv/bin/python \ + --args /opt/routerai-image-mcp/server.py +``` + +На вопрос «Enable all N tools?» ответить `Y`. Проверка: + +```bash +hermes mcp test routerai-image +# ✓ Connected, Tools discovered: 2 → generate_image, list_image_models +``` + +⚠️ **Инструменты появляются только в новой сессии** — MCP-серверы +подключаются при старте Hermes; в уже идущем диалоге их не будет. + +Если сервер развёрнут на отдельной машине, вместо stdio удобнее HTTP: + +```bash +hermes mcp add routerai-image --url http://:8790/mcp +``` + +## Подключение к другим клиентам + +opencode (`~/.config/opencode/config.json`): + +```json +{ + "mcp": { + "routerai-image": { + "type": "local", + "command": ["/usr/local/lib/hermes-agent/venv/bin/python", "/opt/routerai-image-mcp/server.py"], + "enabled": true + } + } +} +``` + +Claude Desktop / прочие stdio-клиенты — тот же `command` + `args`. + +## Инструменты + +### `generate_image` + +| Аргумент | Тип | Смысл | +|---|---|---| +| `prompt` | str | ТЗ: сюжет, стиль, свет, композиция, пропорции | +| `model` | str | id модели RouterAI (дефолт `google/gemini-3.1-flash-image`) | +| `reference_images` | list[str] | Пути к картинкам для правки/референса (image-to-image) | +| `out_dir` | str | Куда сложить файл | +| `filename` | str | Имя файла (расширение подставится само) | + +Возвращает текст с абсолютным путём и строкой `MEDIA:<путь>`. **Чтобы клиент +показал картинку, модель должна вставить эту строку `MEDIA:<путь>` целиком в +свой ответ.** + +### `list_image_models` + +Печатает список поддерживаемых моделей, их бэкенды и дефолт. + +## Модели и стоимость + +| id | Что это | Цена (факт, за картинку) | +|---|---|---| +| `google/gemini-3.1-flash-image` | Nano Banana 2 — дефолт | ~7.5 ₽ | +| `google/gemini-3.1-flash-lite-image` | Nano Banana 2 Lite — самый дешёвый | ~3.7 ₽ | +| `google/gemini-2.5-flash-image` | Nano Banana (2.5) | ~4.3 ₽ | +| `google/gemini-3-pro-image` | Nano Banana Pro | ~13 ₽ | +| `black-forest-labs/flux.2-klein-4b` | FLUX.2 Klein — дешёвый, без правки | — | +| `black-forest-labs/flux.2-pro` / `-max` / `-flex` | FLUX.2 Pro/Max/Flex | — | +| `openai/gpt-image-1` / `-mini` | GPT Image | — | + +Ключи без значения — считаются по своему тарифу, проверяйте на +`GET https://routerai.ru/api/v1/models` (поле `pricing.image_output`). +Gemini-Image дороже других, зато понимает сложные ТЗ и умеет правку по референсу. + +## Проверка + +```bash +# список инструментов по протоколу + генерация +/usr/local/lib/hermes-agent/venv/bin/python test_client.py --generate google/gemini-3.1-flash-lite-image + +# правка существующей картинки по референсу +/usr/local/lib/hermes-agent/venv/bin/python test_edit.py /путь/к/картинке.jpg +``` + +Оба скрипта поднимают сервер по stdio и работают с ним как настоящий MCP-клиент. + +## Питфоллы + +- **`llm.binom.pw` может лежать (502), а `routerai.ru` — работать.** Сервер + ходит напрямую в `routerai.ru` и не зависит от Bifrost/llm-proxy. Если у вас + всё настроено через роутер и он упал — картинки всё равно будут. +- **Путь к интерпретатору.** `python3` из системы может не иметь пакета `mcp`. + В Hermes надёжнее указывать его venv: `/usr/local/lib/hermes-agent/venv/bin/python`. +- **Картинки нужны модели, а не агенту.** Возвращается путь к файлу, а не + base64 в контекст — контекст не забивается мегабайтами. +- **Модели, отдающие картинки иначе.** `flux.2-*` и `gpt-image-*` не принимают + `modalities` — для них используется `/images/generations`; определяется + автоматически по таблице `MODELS` в начале `server.py`. Новая модель из + другого семейства — дописать её туда. +- **RouterAI отдаёт префикс-мусор перед JSON** на `/images/generations` + (переводы строк и пробелы) — парсер ищет первый `{`. +- **429/5xx** — сервер сам делает 3 повтора с нарастающей паузой; 403 + `model_blocked` означает, что модель не разрешена ключу на роутере. + +## Лицензия + +Apache License 2.0 — см. `LICENSE`. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a5aa6e2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +mcp>=1.2.0 diff --git a/server.py b/server.py new file mode 100644 index 0000000..6b0c5bf --- /dev/null +++ b/server.py @@ -0,0 +1,289 @@ +#!/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() diff --git a/test_client.py b/test_client.py new file mode 100644 index 0000000..05b0304 --- /dev/null +++ b/test_client.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Проверка routerai-image-mcp по протоколу stdio: tools/list + tools/call.""" +import asyncio, os, sys + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +PY = "/usr/local/lib/hermes-agent/venv/bin/python" +SERVER = "/opt/routerai-image-mcp/server.py" + + +async def main() -> None: + params = StdioServerParameters( + command=PY, + args=[SERVER], + env={**os.environ, "IMAGE_MCP_OUT_DIR": "/tmp/mcp-img-test"}, + ) + async with stdio_client(params) as (r, w): + async with ClientSession(r, w) as s: + await s.initialize() + tools = await s.list_tools() + print("TOOLS:", [t.name for t in tools.tools]) + for t in tools.tools: + print(" -", t.name, "::", (t.description or "").splitlines()[0][:90]) + + res = await s.call_tool("list_image_models", {}) + print("\n--- list_image_models ---") + print(res.content[0].text[:300]) + + if len(sys.argv) > 1 and sys.argv[1] == "--generate": + model = sys.argv[2] if len(sys.argv) > 2 else "google/gemini-3.1-flash-lite-image" + print(f"\n--- generate_image ({model}) ---") + res = await s.call_tool( + "generate_image", + {"prompt": "рыжий кот в будёновке, акварель, тёплый фон", "model": model}, + ) + print(res.content[0].text) + print("isError:", res.isError) + + +asyncio.run(main()) diff --git a/test_edit.py b/test_edit.py new file mode 100644 index 0000000..82f18fb --- /dev/null +++ b/test_edit.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Проверка image-to-image: правка существующей картинки по референсу.""" +import asyncio, os, sys +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +PY = "/usr/local/lib/hermes-agent/venv/bin/python" +SERVER = "/opt/routerai-image-mcp/server.py" +REF = sys.argv[1] + + +async def main() -> None: + params = StdioServerParameters( + command=PY, args=[SERVER], + env={**os.environ, "IMAGE_MCP_OUT_DIR": "/tmp/mcp-img-test"}, + ) + async with stdio_client(params) as (r, w): + async with ClientSession(r, w) as s: + await s.initialize() + res = await s.call_tool("generate_image", { + "prompt": "Оставь кота как есть, но поменяй фон на зимний: падает снег, ёлки.", + "model": "google/gemini-3.1-flash-image", + "reference_images": [REF], + "filename": "kot-edit.png", + }) + print(res.content[0].text) + print("isError:", res.isError) + + +asyncio.run(main())