@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 CHANGED
@@ -1,84 +1,123 @@
1
- [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/stabgan-openrouter-mcp-multimodal-badge.png)](https://mseep.ai/app/stabgan-openrouter-mcp-multimodal)
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 &middot; ~950 npm installs/month and accelerating</sub>
28
+ </p>
29
+
30
+ <p align="center">
31
+ <a href="#one-click-install">Install</a> &middot;
32
+ <a href="#tools">Tools</a> &middot;
33
+ <a href="#quick-start">Quick Start</a> &middot;
34
+ <a href="#configuration">Config</a> &middot;
35
+ <a href="#usage-examples">Examples</a> &middot;
36
+ <a href="#architecture">Architecture</a> &middot;
37
+ <a href="./CHANGELOG.md">Changelog</a>
38
+ </p>
39
+
40
+ ---
41
+ [![Verified on MseeP](https://mseep.ai/badge.svg)](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
- [![npm version](https://img.shields.io/npm/v/@stabgan/openrouter-mcp-multimodal.svg)](https://www.npmjs.com/package/@stabgan/openrouter-mcp-multimodal)
6
- [![Docker Pulls](https://img.shields.io/docker/pulls/stabgan/openrouter-mcp-multimodal.svg)](https://hub.docker.com/r/stabgan/openrouter-mcp-multimodal)
7
- [![Build Status](https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml/badge.svg)](https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml)
8
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
- Add this MCP server to your AI tool. Every button below was regenerated from the current install spec of each tool and verified against its official docs (see [`scripts/make-install-links.mjs`](./scripts/make-install-links.mjs) for the payloads).
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
- | Tool | Install |
19
- | ---- | ------- |
20
- | **Kiro** | [![Add to Kiro](https://img.shields.io/badge/Add_to-Kiro-232F3E?logo=amazonaws&logoColor=white)](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** | [![Add to Cursor](https://img.shields.io/badge/Add_to-Cursor-000?logo=cursor&logoColor=white)](cursor://anysphere.cursor-deeplink/mcp/install?name=openrouter&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBzdGFiZ2FuL29wZW5yb3V0ZXItbWNwLW11bHRpbW9kYWwiXSwiZW52Ijp7Ik9QRU5ST1VURVJfQVBJX0tFWSI6InNrLW9yLXYxLS4uLiJ9fQ==) |
22
- | **VS Code** | [![Add to VS Code](https://img.shields.io/badge/Add_to-VS_Code-007ACC?logo=visualstudiocode&logoColor=white)](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** | [![Add to VS Code Insiders](https://img.shields.io/badge/Add_to-VS_Code_Insiders-24bfa5?logo=visualstudiocode&logoColor=white)](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
- README install-link audit (2026-04-20):
33
- - Kiro button uses the current schema from https://kiro.dev/docs/mcp/servers/#install-link-schema
34
- (https://kiro.dev/launch/mcp/add?name=<name>&config=<url-encoded JSON of the server body>)
35
- v2's button pointed at https://kiro.dev/mcp?url=... which is not a supported route.
36
- - Cursor button uses cursor://anysphere.cursor-deeplink/mcp/install?name=<name>&config=<base64>
37
- where the base64 decodes to just the server body ({command, args, env}).
38
- v2's button wrapped it in {mcpServers:{openrouter:{...}}} which Cursor does not accept.
39
- - VS Code button uses vscode:mcp/install?<URL-encoded JSON> where the JSON is
40
- {name, command, args, env}. v2's button used vscode://ms-vscode.vscode-mcp/install
41
- (wrong scheme) and the mcpServers-wrapped payload. Fixed.
42
- - Regenerate with: node scripts/make-install-links.mjs
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 | This Server |
48
- | ---------------------------- | ----------------------------------------------------------------- |
49
- | Text chat with 300+ models | ✅ |
50
- | Image analysis (vision) | ✅ Native with sharp optimization |
51
- | Audio analysis | ✅ Transcription + analysis, base64 auto-encoded |
52
- | Audio generation | ✅ Conversational audio, speech, and music with format auto-detection |
53
- | Image generation | ✅ Path-sandboxed disk output |
54
- | **Video understanding** | ✅ **v3** — mp4, mpeg, mov, webm from files, URLs, or data URLs |
55
- | **Video generation** | ✅ **v3** — Veo 3.1 / Sora 2 Pro / Seedance / Wan via OpenRouter's async API, with progress notifications and resumable jobs |
56
- | Auto image resize & compress | ✅ (configurable; defaults 800px max, JPEG 80%) |
57
- | Model search & validation | ✅ Filter by vision / audio / video input modality |
58
- | Free model support | ✅ (default: free Nemotron VL) |
59
- | Docker support | ✅ Multi-arch (linux/amd64 + linux/arm64), ~345 MB Alpine |
60
- | HTTP client | ✅ Node.js native `fetch`, Retry-After + jitter, IPv4/IPv6 SSRF blocklist |
61
- | Structured errors | Closed `_meta.code` taxonomy so clients can switch on failure modes |
62
- | Reasoning-model awareness | ✅ Detects `max_tokens` cutoff during CoT and guides the caller |
63
- | MCP 2025 annotations | ✅ Every tool ships `readOnlyHint` / `destructiveHint` / `idempotentHint` |
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 | Description |
68
- | ----------------- | --------------------------------------------------------------------------------------------------- |
69
- | `chat_completion` | Send messages to any OpenRouter model. Detects reasoning-model cutoffs and surfaces them clearly. |
70
- | `analyze_image` | Analyze images from local files, URLs, or data URIs. Auto-optimized with sharp. |
71
- | `analyze_audio` | Analyze/transcribe audio from local files, URLs, or data URIs. Supports WAV, MP3, FLAC, OGG, etc. |
72
- | `analyze_video` | Analyze/transcribe video (mp4, mpeg, mov, webm) from local files, URLs, or base64 data URIs. |
73
- | `generate_audio` | Generate audio from text. Auto-detects output format, wraps raw PCM in WAV, optional disk save. |
74
- | `generate_image` | Generate images from text prompts. Optional disk save (path-sandboxed). |
75
- | `generate_video` | Generate video via OpenRouter's async API (Veo 3.1 / Sora 2 Pro / Seedance / Wan). Submits, polls, downloads, saves. |
76
- | `get_video_status`| Resume polling a `generate_video` job by id, download + save the result when complete. |
77
- | `search_models` | Search/filter models by name, provider, or capabilities (vision / audio / video). |
78
- | `get_model_info` | Get pricing, context length, and capabilities for any model. |
79
- | `validate_model` | Check if a model ID exists on OpenRouter. |
80
-
81
- All error responses carry `_meta.code` from a closed error 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`) so MCP clients can switch on failure modes without parsing strings.
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
- | Environment Variable | Required | Default | Description |
152
- | ------------------------------------- | -------- | ------------------------------------- | ----------------------------------------------------- |
153
- | `OPENROUTER_API_KEY` | Yes | — | Your OpenRouter API key |
154
- | `OPENROUTER_DEFAULT_MODEL` | No | `nvidia/nemotron-nano-12b-v2-vl:free` | Default model for chat, analyze, and similar tools |
155
- | `DEFAULT_MODEL` | No | | Alias for `OPENROUTER_DEFAULT_MODEL` |
156
- | `OPENROUTER_MODEL_CACHE_TTL_MS` | No | `3600000` | How long cached `/models` data is valid (ms) |
157
- | `OPENROUTER_IMAGE_MAX_DIMENSION` | No | `800` | Longest edge for resize before vision requests (px) |
158
- | `OPENROUTER_IMAGE_JPEG_QUALITY` | No | `80` | JPEG quality after optimization (1–100) |
159
- | `OPENROUTER_IMAGE_FETCH_TIMEOUT_MS` | No | `30000` | Per-request timeout for image URLs |
160
- | `OPENROUTER_IMAGE_MAX_DOWNLOAD_BYTES` | No | `26214400` | Max bytes when downloading an image URL (~25 MB) |
161
- | `OPENROUTER_IMAGE_MAX_REDIRECTS` | No | `8` | Max HTTP redirects when fetching an image URL |
162
- | `OPENROUTER_IMAGE_MAX_DATA_URL_BYTES` | No | `20971520` | Approx max decoded size for base64 data URLs (~20 MB) |
163
- | `OPENROUTER_AUDIO_FETCH_TIMEOUT_MS` | No | `30000` | Per-request timeout for audio URLs |
164
- | `OPENROUTER_AUDIO_MAX_DOWNLOAD_BYTES` | No | `26214400` | Max bytes when downloading an audio URL (~25 MB) |
165
- | `OPENROUTER_AUDIO_MAX_REDIRECTS` | No | `8` | Max HTTP redirects when fetching an audio URL |
166
- | `OPENROUTER_AUDIO_MAX_DATA_URL_BYTES` | No | `20971520` | Approx max decoded size for base64 audio data URLs |
167
- | `OPENROUTER_DEFAULT_VIDEO_MODEL` | No | `google/gemini-2.5-flash` | Default model for `analyze_video` |
168
- | `OPENROUTER_DEFAULT_VIDEO_GEN_MODEL` | No | `google/veo-3.1` | Default model for `generate_video` |
169
- | `OPENROUTER_VIDEO_FETCH_TIMEOUT_MS` | No | `60000` | Per-request timeout for video URLs |
170
- | `OPENROUTER_VIDEO_MAX_DOWNLOAD_BYTES` | No | `104857600` | Max bytes when downloading a video URL (~100 MB) |
171
- | `OPENROUTER_VIDEO_MAX_REDIRECTS` | No | `8` | Max HTTP redirects when fetching a video URL |
172
- | `OPENROUTER_VIDEO_MAX_DATA_URL_BYTES` | No | `104857600` | Approx max decoded size for base64 video data URLs |
173
- | `OPENROUTER_VIDEO_POLL_INTERVAL_MS` | No | `15000` | Poll cadence for async video generation |
174
- | `OPENROUTER_VIDEO_MAX_WAIT_MS` | No | `600000` | Max wait for a `generate_video` job before returning a resumable handle |
175
- | `OPENROUTER_VIDEO_GEN_MAX_BYTES` | No | `268435456` | Max bytes for a generated-video download (~256 MB) |
176
- | `OPENROUTER_VIDEO_INLINE_MAX_BYTES` | No | `10485760` | Inline video content block ceiling (~10 MB). Larger outputs are referenced by `save_path` only. |
177
- | `OPENROUTER_OUTPUT_DIR` | No | `process.cwd()` | Sandbox root for `save_path` on generate tools. Writes outside this directory are rejected. |
178
- | `OPENROUTER_ALLOW_UNSAFE_PATHS` | No | | Set to `1` to disable the output sandbox (legacy v2 behavior). Not recommended. |
179
- | `OPENROUTER_LOG_LEVEL` | No | `info` | `error` \| `warn` \| `info` \| `debug` — JSON logs on stderr. |
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
- - **`analyze_image`** can read **local files** the Node process can read and can **fetch HTTP(S) URLs**. URL fetches block private/link-local/reserved IPv4 and IPv6 targets (SSRF mitigation) and cap response size; they are still **server-side** requests — avoid pointing at internal-only hosts you rely on staying private.
184
- - **`analyze_audio`** can read **local audio files** and **fetch HTTP(S) URLs**. Same SSRF protections apply. Audio is base64-encoded before sending to OpenRouter (handled automatically).
185
- - **`generate_audio`** and **`generate_image`** write to disk through a path sandbox: `save_path` is resolved against `OPENROUTER_OUTPUT_DIR` (default: the current working directory) and any attempt to escape the root via `..`, absolute paths, or symlinks is rejected. Override with `OPENROUTER_OUTPUT_DIR=/some/dir` or, for legacy v2 behavior, `OPENROUTER_ALLOW_UNSAFE_PATHS=1`.
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 generate_audio with model "google/lyria-3-clip-preview" and prompt:
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
- The file will be auto-saved as `jazz.mp3` since Lyria returns MP3.
239
+ # Video understanding
240
+ Use analyze_video on /path/to/clip.mp4 — what happens at 00:15?
231
241
 
232
- ### Find Vision Models
242
+ # Generate audio
243
+ Use generate_audio with prompt "Explain neural networks" and voice "alloy", save to ./response.wav
233
244
 
234
- ```
235
- Use search_models with capabilities.vision = true to find models that can see images.
236
- ```
245
+ # Generate music
246
+ Use generate_audio with model "google/lyria-3-clip-preview" and prompt "upbeat jazz piano trio"
237
247
 
238
- ### Generate an Image
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
- ### Generate Video
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 # Server entry, env validation, graceful shutdown
264
- ├── tool-handlers.ts # Tool registration (11 tools, annotated) + dispatch
265
- ├── model-cache.ts # In-memory model cache — TTL + in-flight coalescing
266
- ├── openrouter-api.ts # REST client (chat + /videos submit/poll/download)
267
- ├── errors.ts # Closed ErrorCode enum + toolError helper
268
- ├── logger.ts # JSON-line structured logger (stderr)
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 # Shared SSRF, bounded fetch, data-URL parser
271
- ├── openrouter-errors.ts # SDK/HTTP error → ErrorCode classifier
272
- ├── completion-utils.ts # Reasoning-model cutoff detection + text extraction
273
- ├── path-safety.ts # save_path sandbox for generate tools
274
- ├── chat-completion.ts # Text & multimodal chat
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 / analysis
277
- ├── analyze-video.ts # Video understanding (v3)
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 with streaming + format detection
280
- ├── generate-video.ts # Video generation async submit/poll/download (v3)
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, base64 encoding
283
- ├── video-utils.ts # Video format detection (mp4/mov/webm/mpeg), base64 (v3)
284
- ├── search-models.ts # Model search with capability filtering
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 # Unit tests (163 tests, <1 s, no API key needed)
323
- npm run test:integration # Live API tests (needs OPENROUTER_API_KEY in .env)
301
+ npm test # 163 unit tests, <1s
302
+ npm run test:integration # Live API tests
324
303
  npm run lint
325
- npm run format:check
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. What changed:
309
+ v3 is **additive** — no tool schemas or env vars were removed.
343
310
 
344
- - **Three new tools**: `analyze_video`, `generate_video`, `get_video_status`. Ignore them if you don't need video.
345
- - **Structured error `_meta.code`** on every handler's error response. Text messages are preserved; clients that ignored `_meta` before continue to work.
346
- - **`save_path` now sandboxed by default**. Generated images/audio/video default to `process.cwd()`. Set `OPENROUTER_OUTPUT_DIR=/some/dir` to widen the root, or `OPENROUTER_ALLOW_UNSAFE_PATHS=1` to restore v2 behavior.
347
- - **Reasoning-model awareness**: if you were pointing `chat_completion` or `analyze_image` at a reasoning model with tight `max_tokens`, you'd previously get empty strings back. v3 returns `INVALID_INPUT` with a reasoning preview instead. Raise `max_tokens` or pick a non-reasoning model.
348
- - **IPv6 SSRF coverage extended** to IPv4-mapped, IPv4-compatible, multicast, 6to4 of private ranges, Teredo, ORCHID, and documentation ranges. If you were reaching private IPv6 targets (unusual), audit your setup.
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.0.0' }, { capabilities: { tools: {} } });
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 () => {
@@ -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, { redirect: 'manual', signal: controller.signal });
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
- completion = await openai.chat.completions.create({
27
- model: model || DEFAULT_MODEL,
28
- messages: [{ role: 'user', content: `Generate an image: ${prompt}` }],
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
- const match = message.content.match(/data:image\/([^;]+);base64,([A-Za-z0-9+/=]+)/);
124
- if (match)
125
- return { data: match[2], mime: `image/${match[1]}` };
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 match = url.match(/^data:([^;]+);base64,(.+)$/);
133
- return match ? { data: match[2], mime: match[1] } : null;
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: string;
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
- const msg = error instanceof Error ? error.message : String(error);
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 { content: [{ type: 'text', text: 'No model data available.' }], isError: true };
17
+ return toolError(ErrorCode.INTERNAL, 'No model data available.');
14
18
  }
15
19
  const info = modelCache.get(model);
16
20
  if (!info) {
17
- return { content: [{ type: 'text', text: `Model '${model}' not found.` }], isError: true };
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: string;
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
- const results = modelCache.search(request.params.arguments);
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
- const msg = error instanceof Error ? error.message : String(error);
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: string;
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
- const msg = error instanceof Error ? error.message : String(error);
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 { content: [{ type: 'text', text: 'No model data available.' }], isError: true };
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(request.params.arguments.model) }),
23
+ text: JSON.stringify({ valid: modelCache.has(model) }),
19
24
  },
20
25
  ],
21
26
  };
@@ -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.0.0",
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",