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, питфоллы
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
*.png
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.webp
|
||||||
|
.env
|
||||||
@@ -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.
|
||||||
@@ -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://<host>: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://<host>: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`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
mcp>=1.2.0
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user