@stabgan/openrouter-mcp-multimodal 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +187 -228
- package/dist/index.js +1 -1
- package/dist/openrouter-api.js +8 -0
- package/dist/tool-handlers/fetch-utils.d.ts +11 -0
- package/dist/tool-handlers/fetch-utils.js +50 -1
- package/dist/tool-handlers/generate-image.d.ts +22 -0
- package/dist/tool-handlers/generate-image.js +73 -10
- package/dist/tool-handlers/get-model-info.d.ts +2 -9
- package/dist/tool-handlers/get-model-info.js +9 -5
- package/dist/tool-handlers/search-models.d.ts +2 -9
- package/dist/tool-handlers/search-models.js +9 -3
- package/dist/tool-handlers/validate-model.d.ts +2 -9
- package/dist/tool-handlers/validate-model.js +9 -4
- package/dist/tool-handlers.js +30 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,84 +1,123 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.png" alt="OpenRouter MCP Multimodal" width="200" height="200" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">OpenRouter MCP Multimodal Server</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>The all-in-one MCP server for 300+ LLMs — text, vision, audio, and video in a single package.</strong>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/@stabgan/openrouter-mcp-multimodal"><img src="https://img.shields.io/npm/v/@stabgan/openrouter-mcp-multimodal.svg?label=npm&color=cb3837&logo=npm" alt="npm version" /></a>
|
|
13
|
+
<a href="https://hub.docker.com/r/stabgan/openrouter-mcp-multimodal"><img src="https://img.shields.io/docker/v/stabgan/openrouter-mcp-multimodal/latest?label=docker&color=2496ed&logo=docker&logoColor=white" alt="Docker version" /></a>
|
|
14
|
+
<a href="https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml"><img src="https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml/badge.svg" alt="CI" /></a>
|
|
15
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="MIT" /></a>
|
|
16
|
+
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%E2%89%A518-43853d?logo=node.js&logoColor=white" alt="Node.js" /></a>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<a href="https://www.npmjs.com/package/@stabgan/openrouter-mcp-multimodal"><img src="https://img.shields.io/npm/dt/@stabgan/openrouter-mcp-multimodal.svg?label=npm%20downloads&color=cb3837&logo=npm" alt="npm downloads" /></a>
|
|
21
|
+
<a href="https://www.npmjs.com/package/@stabgan/openrouter-mcp-multimodal"><img src="https://img.shields.io/npm/dm/@stabgan/openrouter-mcp-multimodal.svg?label=monthly&color=cb3837&logo=npm" alt="npm monthly" /></a>
|
|
22
|
+
<a href="https://hub.docker.com/r/stabgan/openrouter-mcp-multimodal"><img src="https://img.shields.io/docker/pulls/stabgan/openrouter-mcp-multimodal.svg?label=docker%20pulls&color=2496ed&logo=docker&logoColor=white" alt="Docker pulls" /></a>
|
|
23
|
+
<a href="https://github.com/stabgan/openrouter-mcp-multimodal/stargazers"><img src="https://img.shields.io/github/stars/stabgan/openrouter-mcp-multimodal.svg?style=social" alt="GitHub stars" /></a>
|
|
24
|
+
<a href="https://github.com/stabgan/openrouter-mcp-multimodal/network/members"><img src="https://img.shields.io/github/forks/stabgan/openrouter-mcp-multimodal.svg?style=social" alt="GitHub forks" /></a>
|
|
25
|
+
</p>
|
|
26
|
+
<p align="center">
|
|
27
|
+
<sub>3,800+ installs across npm + Docker Hub · ~950 npm installs/month and accelerating</sub>
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
<p align="center">
|
|
31
|
+
<a href="#one-click-install">Install</a> ·
|
|
32
|
+
<a href="#tools">Tools</a> ·
|
|
33
|
+
<a href="#quick-start">Quick Start</a> ·
|
|
34
|
+
<a href="#configuration">Config</a> ·
|
|
35
|
+
<a href="#usage-examples">Examples</a> ·
|
|
36
|
+
<a href="#architecture">Architecture</a> ·
|
|
37
|
+
<a href="./CHANGELOG.md">Changelog</a>
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
[](https://mseep.ai/app/8f27d6d4-0877-4b86-b377-8a33f451e755)
|
|
42
|
+
|
|
43
|
+
Access 300+ LLMs through [OpenRouter](https://openrouter.ai) via the [Model Context Protocol](https://modelcontextprotocol.io). Analyze images, audio, and video. Generate images, audio, and video. Chat with any model. Every tool returns structured `_meta.code` errors so MCP clients can switch on failure modes without parsing strings.
|
|
2
44
|
|
|
3
|
-
# OpenRouter MCP Multimodal Server
|
|
4
45
|
|
|
5
|
-
[](https://www.npmjs.com/package/@stabgan/openrouter-mcp-multimodal)
|
|
6
|
-
[](https://hub.docker.com/r/stabgan/openrouter-mcp-multimodal)
|
|
7
|
-
[](https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml)
|
|
8
|
-
[](https://opensource.org/licenses/MIT)
|
|
9
|
-
|
|
10
|
-
An OpenRouter MCP server with native vision, image generation, audio analysis, audio generation, and v3 video analysis + generation in one package.
|
|
11
|
-
|
|
12
|
-
Access 300+ LLMs through [OpenRouter](https://openrouter.ai) via the [Model Context Protocol](https://modelcontextprotocol.io), with first-class support for multimodal workflows: analyze images / audio / video, generate images / audio / video, and chat — using free or paid models. Every tool returns a structured `_meta.code` on failure so MCP clients can switch on error classes without parsing strings.
|
|
13
46
|
|
|
14
47
|
## One-Click Install
|
|
15
48
|
|
|
16
|
-
|
|
49
|
+
<table>
|
|
50
|
+
<tr><td><strong>Kiro</strong></td><td><a href="https://kiro.dev/launch/mcp/add?name=openrouter&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40stabgan%2Fopenrouter-mcp-multimodal%22%5D%2C%22env%22%3A%7B%22OPENROUTER_API_KEY%22%3A%22sk-or-v1-...%22%7D%2C%22disabled%22%3Afalse%2C%22autoApprove%22%3A%5B%5D%7D"><img src="https://img.shields.io/badge/Add_to-Kiro-232F3E?style=for-the-badge&logo=amazonaws&logoColor=white" alt="Add to Kiro" /></a></td></tr>
|
|
51
|
+
<tr><td><strong>Cursor</strong></td><td><a href="https://cursor.com/en/install-mcp?name=openrouter&config=eyJ0eXBlIjoic3RkaW8iLCJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBzdGFiZ2FuL29wZW5yb3V0ZXItbWNwLW11bHRpbW9kYWwiXSwiZW52Ijp7Ik9QRU5ST1VURVJfQVBJX0tFWSI6InNrLW9yLXYxLS4uLiJ9fQ%3D%3D"><img src="https://cursor.com/deeplink/mcp-install-dark.svg" alt="Add to Cursor" /></a></td></tr>
|
|
52
|
+
<tr><td><strong>VS Code</strong></td><td><a href="https://insiders.vscode.dev/redirect/mcp/install?name=openrouter&config=%7B%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40stabgan%2Fopenrouter-mcp-multimodal%22%5D%2C%22env%22%3A%7B%22OPENROUTER_API_KEY%22%3A%22sk-or-v1-...%22%7D%7D"><img src="https://img.shields.io/badge/Add_to-VS_Code-007ACC?style=for-the-badge&logo=visualstudiocode&logoColor=white" alt="Add to VS Code" /></a></td></tr>
|
|
53
|
+
<tr><td><strong>VS Code Insiders</strong></td><td><a href="https://insiders.vscode.dev/redirect/mcp/install?name=openrouter&config=%7B%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40stabgan%2Fopenrouter-mcp-multimodal%22%5D%2C%22env%22%3A%7B%22OPENROUTER_API_KEY%22%3A%22sk-or-v1-...%22%7D%7D&quality=insiders"><img src="https://img.shields.io/badge/Add_to-VS_Code_Insiders-24bfa5?style=for-the-badge&logo=visualstudiocode&logoColor=white" alt="Add to VS Code Insiders" /></a></td></tr>
|
|
54
|
+
<tr><td><strong>Claude Desktop</strong></td><td><a href="#option-1-npx-no-install">Install Guide</a> — Add to <code>claude_desktop_config.json</code></td></tr>
|
|
55
|
+
<tr><td><strong>Windsurf</strong></td><td><a href="#option-1-npx-no-install">Install Guide</a> — Add to <code>~/.codeium/windsurf/mcp_config.json</code></td></tr>
|
|
56
|
+
<tr><td><strong>Cline</strong></td><td><a href="#option-1-npx-no-install">Install Guide</a> — Add via Cline MCP settings</td></tr>
|
|
57
|
+
<tr><td><strong>Smithery</strong></td><td><code>npx -y @smithery/cli install @stabgan/openrouter-mcp-multimodal --client claude</code></td></tr>
|
|
58
|
+
</table>
|
|
17
59
|
|
|
18
|
-
|
|
19
|
-
| ---- | ------- |
|
|
20
|
-
| **Kiro** | [](https://kiro.dev/launch/mcp/add?name=openrouter&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40stabgan%2Fopenrouter-mcp-multimodal%22%5D%2C%22env%22%3A%7B%22OPENROUTER_API_KEY%22%3A%22sk-or-v1-...%22%7D%2C%22disabled%22%3Afalse%2C%22autoApprove%22%3A%5B%5D%7D) |
|
|
21
|
-
| **Cursor** | [](cursor://anysphere.cursor-deeplink/mcp/install?name=openrouter&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBzdGFiZ2FuL29wZW5yb3V0ZXItbWNwLW11bHRpbW9kYWwiXSwiZW52Ijp7Ik9QRU5ST1VURVJfQVBJX0tFWSI6InNrLW9yLXYxLS4uLiJ9fQ==) |
|
|
22
|
-
| **VS Code** | [](vscode:mcp/install?%7B%22name%22%3A%22openrouter%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40stabgan%2Fopenrouter-mcp-multimodal%22%5D%2C%22env%22%3A%7B%22OPENROUTER_API_KEY%22%3A%22sk-or-v1-...%22%7D%7D) |
|
|
23
|
-
| **VS Code Insiders** | [](vscode-insiders:mcp/install?%7B%22name%22%3A%22openrouter%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40stabgan%2Fopenrouter-mcp-multimodal%22%5D%2C%22env%22%3A%7B%22OPENROUTER_API_KEY%22%3A%22sk-or-v1-...%22%7D%7D) |
|
|
24
|
-
| **Claude Desktop** | [Install Guide](#option-1-npx-no-install) — Add to `claude_desktop_config.json` |
|
|
25
|
-
| **Windsurf** | [Install Guide](#option-1-npx-no-install) — Add to `~/.codeium/windsurf/mcp_config.json` |
|
|
26
|
-
| **Cline** | [Install Guide](#option-1-npx-no-install) — Add via Cline MCP settings |
|
|
27
|
-
| **Smithery** | `npx -y @smithery/cli install @stabgan/openrouter-mcp-multimodal --client claude` |
|
|
28
|
-
|
|
29
|
-
> After clicking, the target client opens a confirmation prompt. You'll still need to paste your `OPENROUTER_API_KEY` — the deeplink ships a placeholder string so no secrets end up in links you share.
|
|
60
|
+
> After clicking, the target client opens a confirmation prompt. You'll need to paste your `OPENROUTER_API_KEY` — the deeplink ships a placeholder so no secrets end up in shared links.
|
|
30
61
|
|
|
31
62
|
<!--
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
63
|
+
Install-link audit (2026-04-20, round 4 — HTTPS redirectors only):
|
|
64
|
+
GitHub Markdown strips non-HTTPS schemes (cursor://, vscode:) from href attributes
|
|
65
|
+
and rewrites them to cursor.sh / nothing. The only deeplinks that survive GitHub's
|
|
66
|
+
sanitizer are HTTPS URLs. Fortunately both Cursor and VS Code publish official
|
|
67
|
+
HTTPS redirector endpoints that hand off to the IDE's native protocol handler:
|
|
68
|
+
|
|
69
|
+
- Kiro: https://kiro.dev/launch/mcp/add?name=<name>&config=<url-encoded JSON>
|
|
70
|
+
- Cursor: https://cursor.com/en/install-mcp?name=<name>&config=<base64>
|
|
71
|
+
(config excludes `name`; badge is cursor.com/deeplink/mcp-install-dark.svg)
|
|
72
|
+
- VS Code: https://insiders.vscode.dev/redirect/mcp/install?name=<name>&config=<urlenc JSON>
|
|
73
|
+
(config excludes `name`; append &quality=insiders for Insiders)
|
|
74
|
+
|
|
75
|
+
Earlier rounds used cursor://anysphere.cursor-deeplink/... and vscode:mcp/install?...
|
|
76
|
+
— both got stripped by GitHub and rendered as broken redirects to cursor.sh or a
|
|
77
|
+
camo.githubusercontent.com image-proxy URL. Pattern confirmed against
|
|
78
|
+
github/github-mcp-server and modelcontextprotocol/servers READMEs.
|
|
79
|
+
|
|
80
|
+
Regenerate with: node scripts/make-install-links.mjs
|
|
43
81
|
-->
|
|
44
82
|
|
|
45
83
|
## Why This One?
|
|
46
84
|
|
|
47
|
-
| Feature
|
|
48
|
-
|
|
|
49
|
-
| Text chat with 300+ models
|
|
50
|
-
| Image analysis (vision)
|
|
51
|
-
| Audio analysis
|
|
52
|
-
| Audio generation
|
|
53
|
-
| Image generation
|
|
54
|
-
| **Video understanding**
|
|
55
|
-
| **Video generation**
|
|
56
|
-
| Auto image resize
|
|
57
|
-
| Model search
|
|
58
|
-
| Free model support
|
|
59
|
-
| Docker support
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
|
|
|
85
|
+
| Feature | Status |
|
|
86
|
+
| :--- | :--- |
|
|
87
|
+
| Text chat with 300+ models | ✅ |
|
|
88
|
+
| Image analysis (vision) | ✅ Native with sharp optimization |
|
|
89
|
+
| Audio analysis | ✅ Transcription + analysis, base64 auto-encoded |
|
|
90
|
+
| Audio generation | ✅ Conversational, speech, and music with format auto-detection |
|
|
91
|
+
| Image generation | ✅ Path-sandboxed disk output |
|
|
92
|
+
| **Video understanding** | ✅ **v3** — mp4, mpeg, mov, webm from files, URLs, or data URLs |
|
|
93
|
+
| **Video generation** | ✅ **v3** — Veo 3.1 / Sora 2 Pro / Seedance / Wan via async API with progress notifications |
|
|
94
|
+
| Auto image resize + compress | ✅ Configurable (defaults 800px max, JPEG 80%) |
|
|
95
|
+
| Model search + validation | ✅ Filter by vision / audio / video modality |
|
|
96
|
+
| Free model support | ✅ Default: free Nemotron VL |
|
|
97
|
+
| Docker support | ✅ Multi-arch (amd64 + arm64), ~345 MB Alpine |
|
|
98
|
+
| Retry-After + jitter | ✅ Honors `Retry-After` header, avoids thundering herd |
|
|
99
|
+
| IPv4 + IPv6 SSRF blocklist | ✅ Covers mapped, compat, multicast, 6to4, Teredo, ORCHID |
|
|
100
|
+
| Structured error taxonomy | ✅ Closed `_meta.code` so clients can switch on failure modes |
|
|
101
|
+
| Reasoning-model awareness | ✅ Detects `max_tokens` cutoff during CoT, guides the caller |
|
|
102
|
+
| MCP 2025 tool annotations | ✅ `readOnlyHint` / `destructiveHint` / `idempotentHint` on every tool |
|
|
64
103
|
|
|
65
104
|
## Tools
|
|
66
105
|
|
|
67
|
-
| Tool
|
|
68
|
-
|
|
|
69
|
-
| `chat_completion` | Send messages to any OpenRouter model. Detects reasoning-model cutoffs
|
|
70
|
-
| `analyze_image`
|
|
71
|
-
| `analyze_audio`
|
|
72
|
-
| `analyze_video`
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
| `generate_video`
|
|
76
|
-
| `get_video_status
|
|
77
|
-
| `search_models`
|
|
78
|
-
| `get_model_info`
|
|
79
|
-
| `validate_model`
|
|
80
|
-
|
|
81
|
-
All error responses carry `_meta.code` from a closed
|
|
106
|
+
| Tool | Description |
|
|
107
|
+
| :--- | :--- |
|
|
108
|
+
| `chat_completion` | Send messages to any OpenRouter model. Detects reasoning-model cutoffs. |
|
|
109
|
+
| `analyze_image` | Analyze images from local files, URLs, or data URIs. Auto-optimized with sharp. |
|
|
110
|
+
| `analyze_audio` | Analyze/transcribe audio (WAV, MP3, FLAC, OGG, etc.) from files, URLs, or data URIs. |
|
|
111
|
+
| `analyze_video` | Analyze/transcribe video (mp4, mpeg, mov, webm) from files, URLs, or data URIs. |
|
|
112
|
+
| `generate_image` | Generate images from text prompts. Optional path-sandboxed disk save. |
|
|
113
|
+
| `generate_audio` | Generate audio from text. Auto-detects format, wraps raw PCM in WAV. |
|
|
114
|
+
| `generate_video` | Generate video via OpenRouter's async API (Veo 3.1 / Sora 2 Pro / Seedance / Wan). Submits, polls, downloads, saves. |
|
|
115
|
+
| `get_video_status` | Resume polling a `generate_video` job by id. Download + save when complete. |
|
|
116
|
+
| `search_models` | Search/filter models by name, provider, or capabilities (vision / audio / video). |
|
|
117
|
+
| `get_model_info` | Get pricing, context length, and capabilities for any model. |
|
|
118
|
+
| `validate_model` | Check if a model ID exists on OpenRouter. |
|
|
119
|
+
|
|
120
|
+
> All error responses carry `_meta.code` from a closed taxonomy: `INVALID_INPUT` · `UNSAFE_PATH` · `UPSTREAM_HTTP` · `UPSTREAM_TIMEOUT` · `UPSTREAM_REFUSED` · `UNSUPPORTED_FORMAT` · `RESOURCE_TOO_LARGE` · `ZDR_INCOMPATIBLE` · `MODEL_NOT_FOUND` · `JOB_FAILED` · `JOB_STILL_RUNNING` · `INTERNAL`
|
|
82
121
|
|
|
83
122
|
## Quick Start
|
|
84
123
|
|
|
@@ -125,16 +164,12 @@ Get a free API key from [openrouter.ai/keys](https://openrouter.ai/keys).
|
|
|
125
164
|
npm install -g @stabgan/openrouter-mcp-multimodal
|
|
126
165
|
```
|
|
127
166
|
|
|
128
|
-
Then add to your MCP config:
|
|
129
|
-
|
|
130
167
|
```json
|
|
131
168
|
{
|
|
132
169
|
"mcpServers": {
|
|
133
170
|
"openrouter": {
|
|
134
171
|
"command": "openrouter-multimodal",
|
|
135
|
-
"env": {
|
|
136
|
-
"OPENROUTER_API_KEY": "sk-or-v1-..."
|
|
137
|
-
}
|
|
172
|
+
"env": { "OPENROUTER_API_KEY": "sk-or-v1-..." }
|
|
138
173
|
}
|
|
139
174
|
}
|
|
140
175
|
}
|
|
@@ -148,163 +183,109 @@ npx -y @smithery/cli install @stabgan/openrouter-mcp-multimodal --client claude
|
|
|
148
183
|
|
|
149
184
|
## Configuration
|
|
150
185
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
|
155
|
-
|
|
|
156
|
-
| `
|
|
157
|
-
| `
|
|
158
|
-
| `
|
|
159
|
-
| `
|
|
160
|
-
| `
|
|
161
|
-
| `
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
| `
|
|
165
|
-
| `
|
|
166
|
-
| `
|
|
167
|
-
| `
|
|
168
|
-
| `
|
|
169
|
-
| `
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
179
|
-
| `
|
|
186
|
+
<details>
|
|
187
|
+
<summary><strong>Environment variables</strong> (click to expand)</summary>
|
|
188
|
+
|
|
189
|
+
| Variable | Required | Default | Description |
|
|
190
|
+
| :--- | :---: | :--- | :--- |
|
|
191
|
+
| `OPENROUTER_API_KEY` | Yes | — | Your OpenRouter API key |
|
|
192
|
+
| `OPENROUTER_DEFAULT_MODEL` | No | `nvidia/nemotron-nano-12b-v2-vl:free` | Default model for chat + analyze tools |
|
|
193
|
+
| `DEFAULT_MODEL` | No | — | Alias for above |
|
|
194
|
+
| `OPENROUTER_MODEL_CACHE_TTL_MS` | No | `3600000` | Model cache TTL (ms) |
|
|
195
|
+
| `OPENROUTER_IMAGE_MAX_DIMENSION` | No | `800` | Longest edge for resize (px) |
|
|
196
|
+
| `OPENROUTER_IMAGE_JPEG_QUALITY` | No | `80` | JPEG quality (1–100) |
|
|
197
|
+
| `OPENROUTER_IMAGE_FETCH_TIMEOUT_MS` | No | `30000` | Image URL timeout |
|
|
198
|
+
| `OPENROUTER_IMAGE_MAX_DOWNLOAD_BYTES` | No | `26214400` | Image URL size cap (~25 MB) |
|
|
199
|
+
| `OPENROUTER_IMAGE_MAX_REDIRECTS` | No | `8` | Image URL redirect cap |
|
|
200
|
+
| `OPENROUTER_IMAGE_MAX_DATA_URL_BYTES` | No | `20971520` | Image data URL size cap (~20 MB) |
|
|
201
|
+
| `OPENROUTER_AUDIO_FETCH_TIMEOUT_MS` | No | `30000` | Audio URL timeout |
|
|
202
|
+
| `OPENROUTER_AUDIO_MAX_DOWNLOAD_BYTES` | No | `26214400` | Audio URL size cap (~25 MB) |
|
|
203
|
+
| `OPENROUTER_AUDIO_MAX_REDIRECTS` | No | `8` | Audio URL redirect cap |
|
|
204
|
+
| `OPENROUTER_AUDIO_MAX_DATA_URL_BYTES` | No | `20971520` | Audio data URL size cap |
|
|
205
|
+
| `OPENROUTER_DEFAULT_VIDEO_MODEL` | No | `google/gemini-2.5-flash` | Default for `analyze_video` |
|
|
206
|
+
| `OPENROUTER_DEFAULT_VIDEO_GEN_MODEL` | No | `google/veo-3.1` | Default for `generate_video` |
|
|
207
|
+
| `OPENROUTER_VIDEO_FETCH_TIMEOUT_MS` | No | `60000` | Video URL timeout |
|
|
208
|
+
| `OPENROUTER_VIDEO_MAX_DOWNLOAD_BYTES` | No | `104857600` | Video URL size cap (~100 MB) |
|
|
209
|
+
| `OPENROUTER_VIDEO_MAX_REDIRECTS` | No | `8` | Video URL redirect cap |
|
|
210
|
+
| `OPENROUTER_VIDEO_MAX_DATA_URL_BYTES` | No | `104857600` | Video data URL size cap |
|
|
211
|
+
| `OPENROUTER_VIDEO_POLL_INTERVAL_MS` | No | `15000` | Async video poll cadence |
|
|
212
|
+
| `OPENROUTER_VIDEO_MAX_WAIT_MS` | No | `600000` | Max wait before returning a resumable handle |
|
|
213
|
+
| `OPENROUTER_VIDEO_GEN_MAX_BYTES` | No | `268435456` | Generated video download cap (~256 MB) |
|
|
214
|
+
| `OPENROUTER_VIDEO_INLINE_MAX_BYTES` | No | `10485760` | Inline video ceiling (~10 MB) |
|
|
215
|
+
| `OPENROUTER_OUTPUT_DIR` | No | `process.cwd()` | Sandbox root for `save_path` |
|
|
216
|
+
| `OPENROUTER_ALLOW_UNSAFE_PATHS` | No | — | `1` disables the sandbox |
|
|
217
|
+
| `OPENROUTER_LOG_LEVEL` | No | `info` | `error` / `warn` / `info` / `debug` |
|
|
218
|
+
|
|
219
|
+
</details>
|
|
180
220
|
|
|
181
221
|
### Security notes
|
|
182
222
|
|
|
183
|
-
-
|
|
184
|
-
-
|
|
185
|
-
-
|
|
186
|
-
- **IPv6 SSRF blocklist** covers loopback, unspecified, IPv4-mapped, IPv4-compatible, link-local, site-local, ULA, multicast, documentation, Teredo, ORCHID, and 6to4 of private IPv4. If you need to reach a specifically reserved range, you'll have to fork.
|
|
223
|
+
- **Analyze tools** can read local files and fetch HTTP(S) URLs. URL fetches block private/link-local/reserved IPv4 and IPv6 targets (SSRF mitigation) and cap response size.
|
|
224
|
+
- **Generate tools** write to disk through a path sandbox: `save_path` is resolved against `OPENROUTER_OUTPUT_DIR` and any traversal attempt is rejected. Override with `OPENROUTER_ALLOW_UNSAFE_PATHS=1`.
|
|
225
|
+
- **IPv6 SSRF blocklist** covers loopback, unspecified, IPv4-mapped, IPv4-compatible, link-local, site-local, ULA, multicast, documentation, Teredo, ORCHID, and 6to4 of private IPv4.
|
|
187
226
|
|
|
188
227
|
## Usage Examples
|
|
189
228
|
|
|
190
|
-
### Chat
|
|
191
|
-
|
|
192
229
|
```
|
|
230
|
+
# Chat
|
|
193
231
|
Use chat_completion to explain quantum computing in simple terms.
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
### Analyze an Image
|
|
197
232
|
|
|
198
|
-
|
|
233
|
+
# Vision
|
|
199
234
|
Use analyze_image on /path/to/photo.jpg and tell me what you see.
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
### Analyze Audio
|
|
203
|
-
|
|
204
|
-
```
|
|
205
|
-
Use analyze_audio on /path/to/recording.mp3 with model "google/gemini-2.5-flash" to transcribe it.
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
### Analyze Video
|
|
209
|
-
|
|
210
|
-
```
|
|
211
|
-
Use analyze_video on /path/to/clip.mp4 with question "what happens at 00:15?" to get a scene-by-scene description.
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
Supports mp4, mpeg, mov, webm (from local files, HTTP(S) URLs, or base64 data URLs). URLs are fetched through the same SSRF-protected path as image and audio URLs.
|
|
215
|
-
|
|
216
|
-
### Generate Conversational Audio
|
|
217
|
-
|
|
218
|
-
```
|
|
219
|
-
Use generate_audio with prompt "Explain what a neural network is" and voice "alloy", save to ./response.wav
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
### Generate Music (Lyria)
|
|
223
235
|
|
|
224
|
-
|
|
225
|
-
Use
|
|
226
|
-
"upbeat jazz piano trio with walking bass and brushed snare"
|
|
227
|
-
Save to ./jazz.wav
|
|
228
|
-
```
|
|
236
|
+
# Audio transcription
|
|
237
|
+
Use analyze_audio on /path/to/recording.mp3 to transcribe it.
|
|
229
238
|
|
|
230
|
-
|
|
239
|
+
# Video understanding
|
|
240
|
+
Use analyze_video on /path/to/clip.mp4 — what happens at 00:15?
|
|
231
241
|
|
|
232
|
-
|
|
242
|
+
# Generate audio
|
|
243
|
+
Use generate_audio with prompt "Explain neural networks" and voice "alloy", save to ./response.wav
|
|
233
244
|
|
|
234
|
-
|
|
235
|
-
Use
|
|
236
|
-
```
|
|
245
|
+
# Generate music
|
|
246
|
+
Use generate_audio with model "google/lyria-3-clip-preview" and prompt "upbeat jazz piano trio"
|
|
237
247
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
```
|
|
241
|
-
Use generate_image with prompt "a cat astronaut on mars, digital art" and save to ./cat.png
|
|
242
|
-
```
|
|
248
|
+
# Generate image
|
|
249
|
+
Use generate_image with prompt "a cat astronaut on mars" and save to ./cat.png
|
|
243
250
|
|
|
244
|
-
|
|
251
|
+
# Generate video
|
|
252
|
+
Use generate_video with model "google/veo-3.1", prompt "a calm river at sunrise",
|
|
253
|
+
resolution 720p, duration 4, save to ./river.mp4
|
|
245
254
|
|
|
246
|
-
|
|
247
|
-
Use generate_video with model "google/veo-3.1", prompt "a calm river at sunrise,
|
|
248
|
-
cinematic", resolution 720p, aspect_ratio 16:9, duration 4, save to ./river.mp4
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
`generate_video` submits the job, polls OpenRouter until it's either `completed` (downloads + saves the mp4) or runs past `max_wait_ms` (returns a `JOB_STILL_RUNNING` handle with `video_id`). Resume with `get_video_status`:
|
|
252
|
-
|
|
253
|
-
```
|
|
255
|
+
# Resume a video job
|
|
254
256
|
Use get_video_status with video_id "vid_abc123" and save_path "./river.mp4"
|
|
255
257
|
```
|
|
256
258
|
|
|
257
|
-
Long-running jobs emit MCP progress notifications every poll so hosts can show a spinner instead of hanging.
|
|
258
|
-
|
|
259
259
|
## Architecture
|
|
260
260
|
|
|
261
261
|
```
|
|
262
262
|
src/
|
|
263
|
-
├── index.ts
|
|
264
|
-
├── tool-handlers.ts
|
|
265
|
-
├── model-cache.ts
|
|
266
|
-
├── openrouter-api.ts
|
|
267
|
-
├── errors.ts
|
|
268
|
-
├── logger.ts
|
|
263
|
+
├── index.ts # Entry, env validation, graceful shutdown
|
|
264
|
+
├── tool-handlers.ts # 11 tools (annotated) + dispatch
|
|
265
|
+
├── model-cache.ts # TTL + in-flight coalescing
|
|
266
|
+
├── openrouter-api.ts # REST client (chat + /videos)
|
|
267
|
+
├── errors.ts # Closed ErrorCode enum
|
|
268
|
+
├── logger.ts # JSON-line structured logger
|
|
269
269
|
└── tool-handlers/
|
|
270
|
-
├── fetch-utils.ts #
|
|
271
|
-
├── openrouter-errors.ts # SDK/HTTP
|
|
272
|
-
├── completion-utils.ts # Reasoning-model cutoff detection
|
|
273
|
-
├── path-safety.ts # save_path sandbox
|
|
274
|
-
├── chat-completion.ts # Text
|
|
270
|
+
├── fetch-utils.ts # SSRF, bounded fetch, data-URL parser
|
|
271
|
+
├── openrouter-errors.ts # SDK/HTTP → ErrorCode classifier
|
|
272
|
+
├── completion-utils.ts # Reasoning-model cutoff detection
|
|
273
|
+
├── path-safety.ts # save_path sandbox
|
|
274
|
+
├── chat-completion.ts # Text + multimodal chat
|
|
275
275
|
├── analyze-image.ts # Vision analysis
|
|
276
|
-
├── analyze-audio.ts # Audio transcription
|
|
277
|
-
├── analyze-video.ts # Video understanding
|
|
276
|
+
├── analyze-audio.ts # Audio transcription
|
|
277
|
+
├── analyze-video.ts # Video understanding
|
|
278
278
|
├── generate-image.ts # Image generation
|
|
279
|
-
├── generate-audio.ts # Audio generation
|
|
280
|
-
├── generate-video.ts # Video generation
|
|
279
|
+
├── generate-audio.ts # Audio generation + streaming
|
|
280
|
+
├── generate-video.ts # Video generation (async)
|
|
281
281
|
├── image-utils.ts # Sharp optimization, MIME sniffing
|
|
282
|
-
├── audio-utils.ts # Audio format detection
|
|
283
|
-
├── video-utils.ts # Video format detection
|
|
284
|
-
├── search-models.ts # Model search
|
|
282
|
+
├── audio-utils.ts # Audio format detection
|
|
283
|
+
├── video-utils.ts # Video format detection
|
|
284
|
+
├── search-models.ts # Model search
|
|
285
285
|
├── get-model-info.ts # Model detail lookup
|
|
286
286
|
└── validate-model.ts # Model existence check
|
|
287
287
|
```
|
|
288
288
|
|
|
289
|
-
Key design decisions:
|
|
290
|
-
|
|
291
|
-
- **Native `fetch`** everywhere — no axios, no node-fetch.
|
|
292
|
-
- **Shared security layer** — IPv4 + IPv6 SSRF blocklists, bounded fetches, redirect cap in `fetch-utils.ts`. IPv6 covers loopback, unspecified, IPv4-mapped, IPv4-compatible, link-local, site-local, ULA, multicast, documentation, Teredo, ORCHID, and 6to4 of private IPv4.
|
|
293
|
-
- **Path sandbox** — every `save_path` is validated via `resolveSafeOutputPath` _before_ spending tokens; symlink-aware realpath checks defeat traversal.
|
|
294
|
-
- **Retry-After-aware backoff** — `fetchWithRetry` honors `Retry-After` (seconds + HTTP-date) and applies jitter to avoid thundering-herd.
|
|
295
|
-
- **Cache coalescing** — concurrent `search_models` / `get_model_info` calls share a single in-flight `/models` request.
|
|
296
|
-
- **Reasoning-model awareness** — `completion-utils.ts` detects `content: null` with trailing `reasoning_details` and `finish_reason === 'length'`, returning a structured `INVALID_INPUT` with a preview and advice instead of an empty string.
|
|
297
|
-
- **Lazy sharp loading** — sharp loads on first image op, not at startup.
|
|
298
|
-
- **Audio format auto-detection** — magic-byte detection for MP3/WAV/FLAC/OGG with reserved-bit rejection; raw PCM wrapped in WAV at a configurable sample rate.
|
|
299
|
-
- **Video generation** — `generate_video` wraps OpenRouter's async submit → poll → download loop with configurable `max_wait_ms` and `poll_interval_ms`, emits MCP `notifications/progress` on every poll, and returns a resumable handle on timeout (use `get_video_status`).
|
|
300
|
-
- **Graceful errors** — every handler returns `{ isError, content, _meta: { code, details? } }`; no uncaught throws reach the MCP transport.
|
|
301
|
-
- **Process safety** — uncaught exceptions and unhandled rejections exit the process (no zombie servers).
|
|
302
|
-
|
|
303
|
-
## Roadmap
|
|
304
|
-
|
|
305
|
-
v3.0.0 ships `analyze_video` + `generate_video` + `get_video_status` alongside a structured error taxonomy, MCP 2025 tool annotations, reasoning-model cutoff detection, and fail-fast path sandboxing. See [`CHANGELOG.md`](./CHANGELOG.md) for the full list of changes.
|
|
306
|
-
|
|
307
|
-
|
|
308
289
|
## Development
|
|
309
290
|
|
|
310
291
|
```bash
|
|
@@ -316,48 +297,26 @@ npm run build
|
|
|
316
297
|
npm start
|
|
317
298
|
```
|
|
318
299
|
|
|
319
|
-
### Run Tests
|
|
320
|
-
|
|
321
300
|
```bash
|
|
322
|
-
npm test
|
|
323
|
-
npm run test:integration
|
|
301
|
+
npm test # 163 unit tests, <1s
|
|
302
|
+
npm run test:integration # Live API tests
|
|
324
303
|
npm run lint
|
|
325
|
-
|
|
326
|
-
node scripts/live-e2e.mjs # Drives every tool over stdio against real OpenRouter
|
|
304
|
+
node scripts/live-e2e.mjs # 16 live E2E scenarios
|
|
327
305
|
```
|
|
328
306
|
|
|
329
|
-
The live E2E harness in `scripts/live-e2e.mjs` spawns the built server, runs each tool once, verifies the structured-error taxonomy (`UPSTREAM_REFUSED`, `UNSAFE_PATH`, `MODEL_NOT_FOUND`, …), and writes a JSON summary to `.mcp-smoke-output/run-results.json`. Requires `OPENROUTER_API_KEY` in `.env`.
|
|
330
|
-
|
|
331
|
-
### Docker Build
|
|
332
|
-
|
|
333
|
-
```bash
|
|
334
|
-
docker build -t openrouter-mcp .
|
|
335
|
-
docker run -i -e OPENROUTER_API_KEY=sk-or-v1-... openrouter-mcp
|
|
336
|
-
```
|
|
337
|
-
|
|
338
|
-
Multi-stage build: 345MB final image (Alpine + vips runtime only).
|
|
339
|
-
|
|
340
307
|
## Upgrading from v2
|
|
341
308
|
|
|
342
|
-
v3 is **additive** — no tool schemas or env vars were removed.
|
|
309
|
+
v3 is **additive** — no tool schemas or env vars were removed.
|
|
343
310
|
|
|
344
|
-
-
|
|
345
|
-
-
|
|
346
|
-
-
|
|
347
|
-
-
|
|
348
|
-
-
|
|
349
|
-
- **`prepare` → `prepublishOnly`** in `package.json` so `npm install` stops triggering a TypeScript rebuild for end users.
|
|
311
|
+
- Three new tools: `analyze_video`, `generate_video`, `get_video_status`
|
|
312
|
+
- Structured `_meta.code` on every error response (text messages preserved)
|
|
313
|
+
- `save_path` sandboxed by default — set `OPENROUTER_OUTPUT_DIR` or `OPENROUTER_ALLOW_UNSAFE_PATHS=1`
|
|
314
|
+
- Reasoning-model awareness: `content: null` + `finish_reason: length` now returns `INVALID_INPUT` with a preview instead of empty string
|
|
315
|
+
- IPv6 SSRF coverage extended to mapped, compat, multicast, 6to4, Teredo, ORCHID
|
|
350
316
|
|
|
351
317
|
## Compatibility
|
|
352
318
|
|
|
353
|
-
Works with any MCP client:
|
|
354
|
-
|
|
355
|
-
- [Kiro](https://kiro.dev)
|
|
356
|
-
- [Claude Desktop](https://claude.ai/download)
|
|
357
|
-
- [Cursor](https://cursor.sh)
|
|
358
|
-
- [Windsurf](https://codeium.com/windsurf)
|
|
359
|
-
- [Cline](https://github.com/cline/cline)
|
|
360
|
-
- Any MCP-compatible client
|
|
319
|
+
Works with any MCP client: [Kiro](https://kiro.dev) · [Claude Desktop](https://claude.ai/download) · [Cursor](https://cursor.sh) · [Windsurf](https://codeium.com/windsurf) · [Cline](https://github.com/cline/cline) · any MCP-compatible client.
|
|
361
320
|
|
|
362
321
|
## License
|
|
363
322
|
|
package/dist/index.js
CHANGED
|
@@ -21,7 +21,7 @@ if (!apiKey) {
|
|
|
21
21
|
process.exit(1);
|
|
22
22
|
}
|
|
23
23
|
const defaultModel = process.env.OPENROUTER_DEFAULT_MODEL || process.env.DEFAULT_MODEL || DEFAULT_MODEL;
|
|
24
|
-
const server = new Server({ name: 'openrouter-multimodal-server', version: '3.
|
|
24
|
+
const server = new Server({ name: 'openrouter-multimodal-server', version: '3.1.0' }, { capabilities: { tools: {} } });
|
|
25
25
|
server.onerror = (error) => console.error('[MCP Error]', error);
|
|
26
26
|
new ToolHandlers(server, apiKey, defaultModel);
|
|
27
27
|
process.on('SIGINT', async () => {
|
package/dist/openrouter-api.js
CHANGED
|
@@ -39,6 +39,14 @@ async function fetchWithRetry(url, init, { retries = 2, timeoutMs = DEFAULT_TIME
|
|
|
39
39
|
if (res.status === 429 || res.status >= 500) {
|
|
40
40
|
if (attempt < retries) {
|
|
41
41
|
const retryAfter = parseRetryAfter(res.headers.get('retry-after'));
|
|
42
|
+
// Release the connection before retrying so undici/pool doesn't
|
|
43
|
+
// keep it open while we sleep.
|
|
44
|
+
try {
|
|
45
|
+
await res.body?.cancel();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* ignore */
|
|
49
|
+
}
|
|
42
50
|
await sleep(backoffWithJitter(attempt, retryAfter));
|
|
43
51
|
continue;
|
|
44
52
|
}
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-Agent we send on outbound fetches from `fetchHttpResource`. Some
|
|
3
|
+
* CDNs/WAFs (notably Wikimedia/Varnish) reject requests without a UA, so
|
|
4
|
+
* we identify ourselves with the package name + current version + a repo
|
|
5
|
+
* URL so origin operators can contact us if our traffic misbehaves.
|
|
6
|
+
*
|
|
7
|
+
* Version is read from package.json at module load so version bumps don't
|
|
8
|
+
* require hand-edits here. Falls back to `dev` if package.json can't be
|
|
9
|
+
* located (e.g. in certain bundled environments).
|
|
10
|
+
*/
|
|
11
|
+
export declare const FETCH_USER_AGENT: string;
|
|
1
12
|
export declare function readEnvInt(name: string, fallback: number, min?: number): number;
|
|
2
13
|
/** Blocks RFC1918, loopback, link-local, CGNAT, metadata. */
|
|
3
14
|
export declare function isBlockedIPv4(ip: string): boolean;
|
|
@@ -4,6 +4,44 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import dns from 'node:dns/promises';
|
|
6
6
|
import net from 'node:net';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
/**
|
|
11
|
+
* User-Agent we send on outbound fetches from `fetchHttpResource`. Some
|
|
12
|
+
* CDNs/WAFs (notably Wikimedia/Varnish) reject requests without a UA, so
|
|
13
|
+
* we identify ourselves with the package name + current version + a repo
|
|
14
|
+
* URL so origin operators can contact us if our traffic misbehaves.
|
|
15
|
+
*
|
|
16
|
+
* Version is read from package.json at module load so version bumps don't
|
|
17
|
+
* require hand-edits here. Falls back to `dev` if package.json can't be
|
|
18
|
+
* located (e.g. in certain bundled environments).
|
|
19
|
+
*/
|
|
20
|
+
export const FETCH_USER_AGENT = (() => {
|
|
21
|
+
const fallback = 'openrouter-mcp-multimodal/dev (+https://github.com/stabgan/openrouter-mcp-multimodal)';
|
|
22
|
+
try {
|
|
23
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
// Walk up a few levels looking for package.json (handles both
|
|
25
|
+
// dist/tool-handlers/… and src/tool-handlers/… layouts).
|
|
26
|
+
for (let hop = 0; hop < 5; hop++) {
|
|
27
|
+
const candidate = path.resolve(here, '../'.repeat(hop), 'package.json');
|
|
28
|
+
try {
|
|
29
|
+
const raw = readFileSync(candidate, 'utf8');
|
|
30
|
+
const pkg = JSON.parse(raw);
|
|
31
|
+
if (pkg?.version && pkg?.name?.includes('openrouter-mcp-multimodal')) {
|
|
32
|
+
return `openrouter-mcp-multimodal/${pkg.version} (+https://github.com/stabgan/openrouter-mcp-multimodal)`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* keep walking */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* fall through */
|
|
42
|
+
}
|
|
43
|
+
return fallback;
|
|
44
|
+
})();
|
|
7
45
|
export function readEnvInt(name, fallback, min = 1) {
|
|
8
46
|
const raw = process.env[name];
|
|
9
47
|
if (raw === undefined || raw === '')
|
|
@@ -295,7 +333,18 @@ export async function fetchHttpResource(urlString, opts) {
|
|
|
295
333
|
const t = setTimeout(() => controller.abort(), opts.timeoutMs);
|
|
296
334
|
let res;
|
|
297
335
|
try {
|
|
298
|
-
res = await fetch(target, {
|
|
336
|
+
res = await fetch(target, {
|
|
337
|
+
redirect: 'manual',
|
|
338
|
+
signal: controller.signal,
|
|
339
|
+
headers: {
|
|
340
|
+
// Some CDNs/WAFs (notably Wikimedia/Varnish) reject requests
|
|
341
|
+
// without a User-Agent with HTTP 400. Identify ourselves so
|
|
342
|
+
// analyze_image / analyze_audio / analyze_video work against
|
|
343
|
+
// those origins. See https://github.com/stabgan/openrouter-mcp-multimodal/issues/13
|
|
344
|
+
'User-Agent': FETCH_USER_AGENT,
|
|
345
|
+
Accept: 'image/*, audio/*, video/*, */*;q=0.8',
|
|
346
|
+
},
|
|
347
|
+
});
|
|
299
348
|
}
|
|
300
349
|
finally {
|
|
301
350
|
clearTimeout(t);
|
|
@@ -3,6 +3,28 @@ export interface GenerateImageToolRequest {
|
|
|
3
3
|
prompt: string;
|
|
4
4
|
model?: string;
|
|
5
5
|
save_path?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Output aspect ratio. Passed through as `image_config.aspect_ratio`.
|
|
8
|
+
* Supported by OpenRouter image models (e.g. `1:1`, `16:9`, `9:16`,
|
|
9
|
+
* `4:3`, `3:4`, `21:9`). Model-dependent — unsupported values fall back
|
|
10
|
+
* to the model's default. See
|
|
11
|
+
* https://openrouter.ai/docs/guides/overview/multimodal/image-generation
|
|
12
|
+
*/
|
|
13
|
+
aspect_ratio?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Output image resolution bucket. Passed through as
|
|
16
|
+
* `image_config.image_size`. Typical values: `0.5K`, `1K` (default),
|
|
17
|
+
* `2K`, `4K`. Model-dependent.
|
|
18
|
+
*/
|
|
19
|
+
image_size?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Upper bound on the completion budget. Without this OpenRouter
|
|
22
|
+
* reserves the model's full context window (~29k for Gemini
|
|
23
|
+
* image models), which can trigger a 402 on low-credit accounts even
|
|
24
|
+
* though the actual generation uses far fewer tokens. 4096 is plenty
|
|
25
|
+
* for the image payload + any caption.
|
|
26
|
+
*/
|
|
27
|
+
max_tokens?: number;
|
|
6
28
|
}
|
|
7
29
|
export declare function handleGenerateImage(request: {
|
|
8
30
|
params: {
|
|
@@ -1,13 +1,42 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
2
|
import { resolveSafeOutputPath, UnsafeOutputPathError } from './path-safety.js';
|
|
3
|
+
import { parseBase64DataUrl } from './fetch-utils.js';
|
|
3
4
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
4
5
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
5
6
|
const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
|
|
7
|
+
// OpenRouter-documented aspect ratios (standard + extended). Extended are
|
|
8
|
+
// only honored by models that support them (e.g. gemini-3.1-flash-image),
|
|
9
|
+
// others fall back to the model's default.
|
|
10
|
+
const VALID_ASPECT_RATIOS = new Set([
|
|
11
|
+
'1:1',
|
|
12
|
+
'2:3',
|
|
13
|
+
'3:2',
|
|
14
|
+
'3:4',
|
|
15
|
+
'4:3',
|
|
16
|
+
'4:5',
|
|
17
|
+
'5:4',
|
|
18
|
+
'9:16',
|
|
19
|
+
'16:9',
|
|
20
|
+
'21:9',
|
|
21
|
+
'1:4',
|
|
22
|
+
'4:1',
|
|
23
|
+
'1:8',
|
|
24
|
+
'8:1',
|
|
25
|
+
]);
|
|
26
|
+
const VALID_IMAGE_SIZES = new Set(['0.5K', '1K', '2K', '4K']);
|
|
6
27
|
export async function handleGenerateImage(request, openai) {
|
|
7
|
-
const { prompt, model, save_path } = request.params.arguments ?? { prompt: '' };
|
|
28
|
+
const { prompt, model, save_path, aspect_ratio, image_size, max_tokens } = request.params.arguments ?? { prompt: '' };
|
|
8
29
|
if (!prompt?.trim()) {
|
|
9
30
|
return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
|
|
10
31
|
}
|
|
32
|
+
// Validate optional shape fields early so callers get a clear error
|
|
33
|
+
// instead of a cryptic upstream 400.
|
|
34
|
+
if (aspect_ratio !== undefined && !VALID_ASPECT_RATIOS.has(aspect_ratio)) {
|
|
35
|
+
return toolError(ErrorCode.INVALID_INPUT, `aspect_ratio '${aspect_ratio}' is not supported. Valid values: ${[...VALID_ASPECT_RATIOS].join(', ')}.`);
|
|
36
|
+
}
|
|
37
|
+
if (image_size !== undefined && !VALID_IMAGE_SIZES.has(image_size)) {
|
|
38
|
+
return toolError(ErrorCode.INVALID_INPUT, `image_size '${image_size}' is not supported. Valid values: ${[...VALID_IMAGE_SIZES].join(', ')}.`);
|
|
39
|
+
}
|
|
11
40
|
// Fail-fast on unsafe paths BEFORE spending tokens.
|
|
12
41
|
let safePathResolved = null;
|
|
13
42
|
if (save_path) {
|
|
@@ -21,12 +50,33 @@ export async function handleGenerateImage(request, openai) {
|
|
|
21
50
|
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
22
51
|
}
|
|
23
52
|
}
|
|
53
|
+
// Assemble the request body. OpenRouter's image-generation guide requires
|
|
54
|
+
// - `modalities: ["image", "text"]` so multimodal models (like Gemini)
|
|
55
|
+
// know to emit an image, not just text;
|
|
56
|
+
// - `image_config.{aspect_ratio, image_size}` for shape control.
|
|
57
|
+
// The OpenAI SDK doesn't type these fields, but passes unknown members
|
|
58
|
+
// through to the server, so we attach them via a typed cast.
|
|
59
|
+
const imageConfig = {};
|
|
60
|
+
if (aspect_ratio)
|
|
61
|
+
imageConfig.aspect_ratio = aspect_ratio;
|
|
62
|
+
if (image_size)
|
|
63
|
+
imageConfig.image_size = image_size;
|
|
64
|
+
const body = {
|
|
65
|
+
model: model || DEFAULT_MODEL,
|
|
66
|
+
messages: [{ role: 'user', content: `Generate an image: ${prompt}` }],
|
|
67
|
+
modalities: ['image', 'text'],
|
|
68
|
+
};
|
|
69
|
+
if (Object.keys(imageConfig).length > 0)
|
|
70
|
+
body.image_config = imageConfig;
|
|
71
|
+
if (typeof max_tokens === 'number' && max_tokens > 0)
|
|
72
|
+
body.max_tokens = max_tokens;
|
|
24
73
|
let completion;
|
|
25
74
|
try {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
75
|
+
// OpenRouter-specific `image_config` isn't in the OpenAI SDK's typings,
|
|
76
|
+
// but the SDK passes unknown fields straight through to the server.
|
|
77
|
+
// We never pass `stream: true`, so the response is always
|
|
78
|
+
// ChatCompletion.
|
|
79
|
+
completion = (await openai.chat.completions.create(body));
|
|
30
80
|
}
|
|
31
81
|
catch (err) {
|
|
32
82
|
return classifyUpstreamError(err, 'generate_image');
|
|
@@ -120,15 +170,28 @@ function extractBase64(message) {
|
|
|
120
170
|
}
|
|
121
171
|
}
|
|
122
172
|
if (typeof message.content === 'string') {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
173
|
+
// Scan the string for an embedded data URL. We deliberately don't use
|
|
174
|
+
// a single regex here because data URLs may carry MIME parameters
|
|
175
|
+
// (e.g. `data:image/png;charset=binary;base64,...`) which trips the
|
|
176
|
+
// naive `data:([^;]+);base64,(.+)` form.
|
|
177
|
+
const start = message.content.indexOf('data:image/');
|
|
178
|
+
if (start >= 0) {
|
|
179
|
+
// Find the end of the data URL: a whitespace or closing quote/paren.
|
|
180
|
+
const tail = message.content.slice(start);
|
|
181
|
+
const end = tail.search(/[\s)"']/);
|
|
182
|
+
const url = end === -1 ? tail : tail.slice(0, end);
|
|
183
|
+
const parsed = parseDataUrl(url);
|
|
184
|
+
if (parsed)
|
|
185
|
+
return parsed;
|
|
186
|
+
}
|
|
126
187
|
}
|
|
127
188
|
return null;
|
|
128
189
|
}
|
|
129
190
|
function parseDataUrl(url) {
|
|
130
191
|
if (!url?.startsWith('data:'))
|
|
131
192
|
return null;
|
|
132
|
-
const
|
|
133
|
-
|
|
193
|
+
const parsed = parseBase64DataUrl(url);
|
|
194
|
+
if (!parsed)
|
|
195
|
+
return null;
|
|
196
|
+
return { data: parsed.base64, mime: parsed.mediaType };
|
|
134
197
|
}
|
|
@@ -6,16 +6,9 @@ export declare function handleGetModelInfo(request: {
|
|
|
6
6
|
model: string;
|
|
7
7
|
};
|
|
8
8
|
};
|
|
9
|
-
}, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<{
|
|
9
|
+
}, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
|
|
10
10
|
content: {
|
|
11
|
-
type:
|
|
11
|
+
type: "text";
|
|
12
12
|
text: string;
|
|
13
13
|
}[];
|
|
14
|
-
isError: boolean;
|
|
15
|
-
} | {
|
|
16
|
-
content: {
|
|
17
|
-
type: string;
|
|
18
|
-
text: string;
|
|
19
|
-
}[];
|
|
20
|
-
isError?: undefined;
|
|
21
14
|
}>;
|
|
@@ -1,20 +1,24 @@
|
|
|
1
|
+
import { ErrorCode, toolError } from '../errors.js';
|
|
2
|
+
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
1
3
|
export async function handleGetModelInfo(request, modelCache, apiClient) {
|
|
2
|
-
const { model } = request.params.arguments;
|
|
4
|
+
const { model } = request.params.arguments ?? { model: '' };
|
|
5
|
+
if (!model || typeof model !== 'string') {
|
|
6
|
+
return toolError(ErrorCode.INVALID_INPUT, 'model is required.');
|
|
7
|
+
}
|
|
3
8
|
if (apiClient) {
|
|
4
9
|
try {
|
|
5
10
|
await modelCache.ensureFresh(() => apiClient.getModels());
|
|
6
11
|
}
|
|
7
12
|
catch (error) {
|
|
8
|
-
|
|
9
|
-
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
13
|
+
return classifyUpstreamError(error, 'get_model_info');
|
|
10
14
|
}
|
|
11
15
|
}
|
|
12
16
|
if (!modelCache.isValid()) {
|
|
13
|
-
return
|
|
17
|
+
return toolError(ErrorCode.INTERNAL, 'No model data available.');
|
|
14
18
|
}
|
|
15
19
|
const info = modelCache.get(model);
|
|
16
20
|
if (!info) {
|
|
17
|
-
return
|
|
21
|
+
return toolError(ErrorCode.MODEL_NOT_FOUND, `Model '${model}' not found.`);
|
|
18
22
|
}
|
|
19
23
|
return { content: [{ type: 'text', text: JSON.stringify(info, null, 2) }] };
|
|
20
24
|
}
|
|
@@ -12,16 +12,9 @@ export declare function handleSearchModels(request: {
|
|
|
12
12
|
params: {
|
|
13
13
|
arguments: SearchModelsArgs;
|
|
14
14
|
};
|
|
15
|
-
}, apiClient: OpenRouterAPIClient, modelCache: ModelCache): Promise<{
|
|
15
|
+
}, apiClient: OpenRouterAPIClient, modelCache: ModelCache): Promise<import("../errors.js").ToolErrorResult | {
|
|
16
16
|
content: {
|
|
17
|
-
type:
|
|
17
|
+
type: "text";
|
|
18
18
|
text: string;
|
|
19
19
|
}[];
|
|
20
|
-
isError?: undefined;
|
|
21
|
-
} | {
|
|
22
|
-
content: {
|
|
23
|
-
type: string;
|
|
24
|
-
text: string;
|
|
25
|
-
}[];
|
|
26
|
-
isError: boolean;
|
|
27
20
|
}>;
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import { ErrorCode, toolErrorFrom } from '../errors.js';
|
|
2
|
+
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
1
3
|
export async function handleSearchModels(request, apiClient, modelCache) {
|
|
2
4
|
try {
|
|
3
5
|
await modelCache.ensureFresh(() => apiClient.getModels());
|
|
4
|
-
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
return classifyUpstreamError(error, 'search_models');
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const results = modelCache.search(request.params.arguments ?? {});
|
|
5
12
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
6
13
|
}
|
|
7
14
|
catch (error) {
|
|
8
|
-
|
|
9
|
-
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
15
|
+
return toolErrorFrom(ErrorCode.INTERNAL, error, 'search_models');
|
|
10
16
|
}
|
|
11
17
|
}
|
|
@@ -6,16 +6,9 @@ export declare function handleValidateModel(request: {
|
|
|
6
6
|
model: string;
|
|
7
7
|
};
|
|
8
8
|
};
|
|
9
|
-
}, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<{
|
|
9
|
+
}, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
|
|
10
10
|
content: {
|
|
11
|
-
type:
|
|
11
|
+
type: "text";
|
|
12
12
|
text: string;
|
|
13
13
|
}[];
|
|
14
|
-
isError: boolean;
|
|
15
|
-
} | {
|
|
16
|
-
content: {
|
|
17
|
-
type: string;
|
|
18
|
-
text: string;
|
|
19
|
-
}[];
|
|
20
|
-
isError?: undefined;
|
|
21
14
|
}>;
|
|
@@ -1,21 +1,26 @@
|
|
|
1
|
+
import { ErrorCode, toolError } from '../errors.js';
|
|
2
|
+
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
1
3
|
export async function handleValidateModel(request, modelCache, apiClient) {
|
|
4
|
+
const { model } = request.params.arguments ?? { model: '' };
|
|
5
|
+
if (!model || typeof model !== 'string') {
|
|
6
|
+
return toolError(ErrorCode.INVALID_INPUT, 'model is required.');
|
|
7
|
+
}
|
|
2
8
|
if (apiClient) {
|
|
3
9
|
try {
|
|
4
10
|
await modelCache.ensureFresh(() => apiClient.getModels());
|
|
5
11
|
}
|
|
6
12
|
catch (error) {
|
|
7
|
-
|
|
8
|
-
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
13
|
+
return classifyUpstreamError(error, 'validate_model');
|
|
9
14
|
}
|
|
10
15
|
}
|
|
11
16
|
if (!modelCache.isValid()) {
|
|
12
|
-
return
|
|
17
|
+
return toolError(ErrorCode.INTERNAL, 'No model data available.');
|
|
13
18
|
}
|
|
14
19
|
return {
|
|
15
20
|
content: [
|
|
16
21
|
{
|
|
17
22
|
type: 'text',
|
|
18
|
-
text: JSON.stringify({ valid: modelCache.has(
|
|
23
|
+
text: JSON.stringify({ valid: modelCache.has(model) }),
|
|
19
24
|
},
|
|
20
25
|
],
|
|
21
26
|
};
|
package/dist/tool-handlers.js
CHANGED
|
@@ -196,6 +196,36 @@ export class ToolHandlers {
|
|
|
196
196
|
properties: {
|
|
197
197
|
prompt: { type: 'string' },
|
|
198
198
|
model: { type: 'string' },
|
|
199
|
+
aspect_ratio: {
|
|
200
|
+
type: 'string',
|
|
201
|
+
description: 'Output aspect ratio (e.g. 1:1, 16:9, 9:16, 4:3, 3:4, 21:9). Model-dependent.',
|
|
202
|
+
enum: [
|
|
203
|
+
'1:1',
|
|
204
|
+
'2:3',
|
|
205
|
+
'3:2',
|
|
206
|
+
'3:4',
|
|
207
|
+
'4:3',
|
|
208
|
+
'4:5',
|
|
209
|
+
'5:4',
|
|
210
|
+
'9:16',
|
|
211
|
+
'16:9',
|
|
212
|
+
'21:9',
|
|
213
|
+
'1:4',
|
|
214
|
+
'4:1',
|
|
215
|
+
'1:8',
|
|
216
|
+
'8:1',
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
image_size: {
|
|
220
|
+
type: 'string',
|
|
221
|
+
description: 'Output resolution bucket. 1K is the default; 0.5K / 2K / 4K are model-dependent.',
|
|
222
|
+
enum: ['0.5K', '1K', '2K', '4K'],
|
|
223
|
+
},
|
|
224
|
+
max_tokens: {
|
|
225
|
+
type: 'number',
|
|
226
|
+
minimum: 1,
|
|
227
|
+
description: 'Cap on completion tokens. Defaults to the model context window, which can trip free-tier quotas; set e.g. 4096 on low-credit accounts.',
|
|
228
|
+
},
|
|
199
229
|
save_path: {
|
|
200
230
|
type: 'string',
|
|
201
231
|
description: 'Optional path to save the image. Routed through the OPENROUTER_OUTPUT_DIR sandbox.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stabgan/openrouter-mcp-multimodal",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"mcpName": "io.github.stabgan/openrouter-multimodal",
|
|
5
5
|
"description": "MCP server for OpenRouter with text chat, image analysis + generation, audio analysis + generation, video analysis, and video generation (Veo 3.1 / Sora 2 Pro / Seedance / Wan)",
|
|
6
6
|
"type": "module",
|