@stabgan/openrouter-mcp-multimodal 2.0.0 → 3.0.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 +137 -43
- package/dist/errors.d.ts +42 -0
- package/dist/errors.js +46 -0
- package/dist/index.js +1 -1
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +47 -0
- package/dist/model-cache.d.ts +10 -0
- package/dist/model-cache.js +31 -1
- package/dist/openrouter-api.d.ts +54 -0
- package/dist/openrouter-api.js +128 -12
- package/dist/tool-handlers/analyze-audio.d.ts +5 -9
- package/dist/tool-handlers/analyze-audio.js +41 -8
- package/dist/tool-handlers/analyze-image.d.ts +5 -9
- package/dist/tool-handlers/analyze-image.js +38 -8
- package/dist/tool-handlers/analyze-video.d.ts +19 -0
- package/dist/tool-handlers/analyze-video.js +93 -0
- package/dist/tool-handlers/audio-utils.js +7 -9
- package/dist/tool-handlers/chat-completion.d.ts +6 -10
- package/dist/tool-handlers/chat-completion.js +27 -7
- package/dist/tool-handlers/completion-utils.d.ts +27 -0
- package/dist/tool-handlers/completion-utils.js +69 -0
- package/dist/tool-handlers/fetch-utils.d.ts +21 -0
- package/dist/tool-handlers/fetch-utils.js +166 -11
- package/dist/tool-handlers/generate-audio.d.ts +32 -12
- package/dist/tool-handlers/generate-audio.js +77 -46
- package/dist/tool-handlers/generate-image.d.ts +26 -10
- package/dist/tool-handlers/generate-image.js +79 -27
- package/dist/tool-handlers/generate-video.d.ts +78 -0
- package/dist/tool-handlers/generate-video.js +353 -0
- package/dist/tool-handlers/get-model-info.js +8 -2
- package/dist/tool-handlers/image-utils.d.ts +17 -1
- package/dist/tool-handlers/image-utils.js +66 -13
- package/dist/tool-handlers/openrouter-errors.d.ts +18 -0
- package/dist/tool-handlers/openrouter-errors.js +99 -0
- package/dist/tool-handlers/path-safety.d.ts +11 -0
- package/dist/tool-handlers/path-safety.js +88 -0
- package/dist/tool-handlers/search-models.js +1 -3
- package/dist/tool-handlers/validate-model.js +8 -2
- package/dist/tool-handlers/video-utils.d.ts +29 -0
- package/dist/tool-handlers/video-utils.js +174 -0
- package/dist/tool-handlers.js +199 -21
- package/package.json +3 -3
- package/dist/__tests__/audio-utils.test.d.ts +0 -1
- package/dist/__tests__/audio-utils.test.js +0 -120
- package/dist/__tests__/fetch-utils.test.d.ts +0 -1
- package/dist/__tests__/fetch-utils.test.js +0 -76
- package/dist/__tests__/generate-audio.test.d.ts +0 -1
- package/dist/__tests__/generate-audio.test.js +0 -90
- package/dist/__tests__/image-utils.test.d.ts +0 -1
- package/dist/__tests__/image-utils.test.js +0 -75
- package/dist/__tests__/integration.test.d.ts +0 -1
- package/dist/__tests__/integration.test.js +0 -219
- package/dist/__tests__/model-cache.test.d.ts +0 -1
- package/dist/__tests__/model-cache.test.js +0 -96
package/README.md
CHANGED
|
@@ -7,51 +7,79 @@
|
|
|
7
7
|
[](https://github.com/stabgan/openrouter-mcp-multimodal/actions/workflows/publish.yml)
|
|
8
8
|
[](https://opensource.org/licenses/MIT)
|
|
9
9
|
|
|
10
|
-
An OpenRouter MCP server with native vision, image generation, audio analysis, audio generation, and
|
|
10
|
+
An OpenRouter MCP server with native vision, image generation, audio analysis, audio generation, and v3 video analysis + generation in one package.
|
|
11
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
|
|
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
13
|
|
|
14
14
|
## One-Click Install
|
|
15
15
|
|
|
16
|
-
Add this MCP server to your
|
|
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).
|
|
17
17
|
|
|
18
18
|
| Tool | Install |
|
|
19
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) |
|
|
21
24
|
| **Claude Desktop** | [Install Guide](#option-1-npx-no-install) — Add to `claude_desktop_config.json` |
|
|
22
|
-
| **Cursor** | [Install Guide](#option-1-npx-no-install) — Add to `.cursor/mcp.json` |
|
|
23
25
|
| **Windsurf** | [Install Guide](#option-1-npx-no-install) — Add to `~/.codeium/windsurf/mcp_config.json` |
|
|
24
26
|
| **Cline** | [Install Guide](#option-1-npx-no-install) — Add via Cline MCP settings |
|
|
25
27
|
| **Smithery** | `npx -y @smithery/cli install @stabgan/openrouter-mcp-multimodal --client claude` |
|
|
26
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.
|
|
30
|
+
|
|
31
|
+
<!--
|
|
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
|
|
43
|
+
-->
|
|
44
|
+
|
|
27
45
|
## Why This One?
|
|
28
46
|
|
|
29
47
|
| Feature | This Server |
|
|
30
48
|
| ---------------------------- | ----------------------------------------------------------------- |
|
|
31
49
|
| Text chat with 300+ models | ✅ |
|
|
32
50
|
| Image analysis (vision) | ✅ Native with sharp optimization |
|
|
33
|
-
| Audio analysis | ✅ Transcription
|
|
34
|
-
| Audio generation | ✅ Conversational audio, speech, and music
|
|
35
|
-
| Image generation | ✅
|
|
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 |
|
|
36
56
|
| Auto image resize & compress | ✅ (configurable; defaults 800px max, JPEG 80%) |
|
|
37
|
-
| Model search & validation | ✅
|
|
57
|
+
| Model search & validation | ✅ Filter by vision / audio / video input modality |
|
|
38
58
|
| Free model support | ✅ (default: free Nemotron VL) |
|
|
39
|
-
| Docker support | ✅ (~
|
|
40
|
-
| HTTP client | ✅ Node.js native `fetch
|
|
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` |
|
|
41
64
|
|
|
42
65
|
## Tools
|
|
43
66
|
|
|
44
67
|
| Tool | Description |
|
|
45
68
|
| ----------------- | --------------------------------------------------------------------------------------------------- |
|
|
46
|
-
| `chat_completion` | Send messages to any OpenRouter model.
|
|
69
|
+
| `chat_completion` | Send messages to any OpenRouter model. Detects reasoning-model cutoffs and surfaces them clearly. |
|
|
47
70
|
| `analyze_image` | Analyze images from local files, URLs, or data URIs. Auto-optimized with sharp. |
|
|
48
|
-
| `analyze_audio` | Analyze/transcribe audio from local files, URLs, or data URIs. Supports WAV, MP3, FLAC, OGG, etc.
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
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). |
|
|
52
78
|
| `get_model_info` | Get pricing, context length, and capabilities for any model. |
|
|
53
79
|
| `validate_model` | Check if a model ID exists on OpenRouter. |
|
|
54
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.
|
|
82
|
+
|
|
55
83
|
## Quick Start
|
|
56
84
|
|
|
57
85
|
### Prerequisites
|
|
@@ -136,13 +164,26 @@ npx -y @smithery/cli install @stabgan/openrouter-mcp-multimodal --client claude
|
|
|
136
164
|
| `OPENROUTER_AUDIO_MAX_DOWNLOAD_BYTES` | No | `26214400` | Max bytes when downloading an audio URL (~25 MB) |
|
|
137
165
|
| `OPENROUTER_AUDIO_MAX_REDIRECTS` | No | `8` | Max HTTP redirects when fetching an audio URL |
|
|
138
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. |
|
|
139
180
|
|
|
140
181
|
### Security notes
|
|
141
182
|
|
|
142
|
-
- **`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.
|
|
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.
|
|
143
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).
|
|
144
|
-
- **`generate_audio`**
|
|
145
|
-
-
|
|
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.
|
|
146
187
|
|
|
147
188
|
## Usage Examples
|
|
148
189
|
|
|
@@ -164,6 +205,14 @@ Use analyze_image on /path/to/photo.jpg and tell me what you see.
|
|
|
164
205
|
Use analyze_audio on /path/to/recording.mp3 with model "google/gemini-2.5-flash" to transcribe it.
|
|
165
206
|
```
|
|
166
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
|
+
|
|
167
216
|
### Generate Conversational Audio
|
|
168
217
|
|
|
169
218
|
```
|
|
@@ -192,38 +241,69 @@ Use search_models with capabilities.vision = true to find models that can see im
|
|
|
192
241
|
Use generate_image with prompt "a cat astronaut on mars, digital art" and save to ./cat.png
|
|
193
242
|
```
|
|
194
243
|
|
|
244
|
+
### Generate Video
|
|
245
|
+
|
|
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
|
+
```
|
|
254
|
+
Use get_video_status with video_id "vid_abc123" and save_path "./river.mp4"
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Long-running jobs emit MCP progress notifications every poll so hosts can show a spinner instead of hanging.
|
|
258
|
+
|
|
195
259
|
## Architecture
|
|
196
260
|
|
|
197
261
|
```
|
|
198
262
|
src/
|
|
199
|
-
├── index.ts # Server entry
|
|
200
|
-
├── tool-handlers.ts # Tool registration
|
|
201
|
-
├── model-cache.ts # In-memory model cache
|
|
202
|
-
├── openrouter-api.ts #
|
|
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)
|
|
203
269
|
└── tool-handlers/
|
|
204
|
-
├── fetch-utils.ts
|
|
205
|
-
├──
|
|
206
|
-
├──
|
|
207
|
-
├──
|
|
208
|
-
├──
|
|
209
|
-
├──
|
|
210
|
-
├──
|
|
211
|
-
├──
|
|
212
|
-
├──
|
|
213
|
-
├──
|
|
214
|
-
|
|
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
|
|
275
|
+
├── analyze-image.ts # Vision analysis
|
|
276
|
+
├── analyze-audio.ts # Audio transcription / analysis
|
|
277
|
+
├── analyze-video.ts # Video understanding (v3)
|
|
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)
|
|
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
|
|
285
|
+
├── get-model-info.ts # Model detail lookup
|
|
286
|
+
└── validate-model.ts # Model existence check
|
|
215
287
|
```
|
|
216
288
|
|
|
217
289
|
Key design decisions:
|
|
218
290
|
|
|
219
|
-
- **Native `fetch`**
|
|
220
|
-
- **Shared security layer** —
|
|
221
|
-
- **
|
|
222
|
-
- **
|
|
223
|
-
- **
|
|
224
|
-
- **
|
|
225
|
-
- **
|
|
226
|
-
- **
|
|
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
|
+
|
|
227
307
|
|
|
228
308
|
## Development
|
|
229
309
|
|
|
@@ -239,12 +319,15 @@ npm start
|
|
|
239
319
|
### Run Tests
|
|
240
320
|
|
|
241
321
|
```bash
|
|
242
|
-
npm test # Unit tests
|
|
322
|
+
npm test # Unit tests (163 tests, <1 s, no API key needed)
|
|
243
323
|
npm run test:integration # Live API tests (needs OPENROUTER_API_KEY in .env)
|
|
244
324
|
npm run lint
|
|
245
325
|
npm run format:check
|
|
326
|
+
node scripts/live-e2e.mjs # Drives every tool over stdio against real OpenRouter
|
|
246
327
|
```
|
|
247
328
|
|
|
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
|
+
|
|
248
331
|
### Docker Build
|
|
249
332
|
|
|
250
333
|
```bash
|
|
@@ -254,6 +337,17 @@ docker run -i -e OPENROUTER_API_KEY=sk-or-v1-... openrouter-mcp
|
|
|
254
337
|
|
|
255
338
|
Multi-stage build: 345MB final image (Alpine + vips runtime only).
|
|
256
339
|
|
|
340
|
+
## Upgrading from v2
|
|
341
|
+
|
|
342
|
+
v3 is **additive** — no tool schemas or env vars were removed. What changed:
|
|
343
|
+
|
|
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.
|
|
350
|
+
|
|
257
351
|
## Compatibility
|
|
258
352
|
|
|
259
353
|
Works with any MCP client:
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closed error-code taxonomy for MCP tool responses. Every handler uses
|
|
3
|
+
* `toolError(code, message, details?)` instead of ad-hoc text so clients
|
|
4
|
+
* can switch on `_meta.code` without regex-parsing free text.
|
|
5
|
+
*
|
|
6
|
+
* Adding a new code requires a design.md note — this set is intentionally
|
|
7
|
+
* small and stable.
|
|
8
|
+
*/
|
|
9
|
+
export declare const ErrorCode: {
|
|
10
|
+
readonly INVALID_INPUT: "INVALID_INPUT";
|
|
11
|
+
readonly UNSAFE_PATH: "UNSAFE_PATH";
|
|
12
|
+
readonly UPSTREAM_HTTP: "UPSTREAM_HTTP";
|
|
13
|
+
readonly UPSTREAM_TIMEOUT: "UPSTREAM_TIMEOUT";
|
|
14
|
+
readonly UPSTREAM_REFUSED: "UPSTREAM_REFUSED";
|
|
15
|
+
readonly UNSUPPORTED_FORMAT: "UNSUPPORTED_FORMAT";
|
|
16
|
+
readonly RESOURCE_TOO_LARGE: "RESOURCE_TOO_LARGE";
|
|
17
|
+
readonly ZDR_INCOMPATIBLE: "ZDR_INCOMPATIBLE";
|
|
18
|
+
readonly MODEL_NOT_FOUND: "MODEL_NOT_FOUND";
|
|
19
|
+
readonly JOB_FAILED: "JOB_FAILED";
|
|
20
|
+
readonly JOB_STILL_RUNNING: "JOB_STILL_RUNNING";
|
|
21
|
+
readonly INTERNAL: "INTERNAL";
|
|
22
|
+
};
|
|
23
|
+
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
24
|
+
export interface ToolErrorResult {
|
|
25
|
+
content: Array<{
|
|
26
|
+
type: 'text';
|
|
27
|
+
text: string;
|
|
28
|
+
}>;
|
|
29
|
+
isError: true;
|
|
30
|
+
_meta: {
|
|
31
|
+
code: ErrorCode;
|
|
32
|
+
details?: Record<string, unknown>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Build a structured MCP error result. */
|
|
36
|
+
export declare function toolError(code: ErrorCode, message: string, details?: Record<string, unknown>): ToolErrorResult;
|
|
37
|
+
/**
|
|
38
|
+
* Convert a caught `unknown` error into a structured tool result. Preserves
|
|
39
|
+
* user-visible messages for known `Error` types and refuses to leak stack
|
|
40
|
+
* traces or raw objects.
|
|
41
|
+
*/
|
|
42
|
+
export declare function toolErrorFrom(code: ErrorCode, err: unknown, prefix?: string): ToolErrorResult;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closed error-code taxonomy for MCP tool responses. Every handler uses
|
|
3
|
+
* `toolError(code, message, details?)` instead of ad-hoc text so clients
|
|
4
|
+
* can switch on `_meta.code` without regex-parsing free text.
|
|
5
|
+
*
|
|
6
|
+
* Adding a new code requires a design.md note — this set is intentionally
|
|
7
|
+
* small and stable.
|
|
8
|
+
*/
|
|
9
|
+
export const ErrorCode = {
|
|
10
|
+
INVALID_INPUT: 'INVALID_INPUT',
|
|
11
|
+
UNSAFE_PATH: 'UNSAFE_PATH',
|
|
12
|
+
UPSTREAM_HTTP: 'UPSTREAM_HTTP',
|
|
13
|
+
UPSTREAM_TIMEOUT: 'UPSTREAM_TIMEOUT',
|
|
14
|
+
UPSTREAM_REFUSED: 'UPSTREAM_REFUSED',
|
|
15
|
+
UNSUPPORTED_FORMAT: 'UNSUPPORTED_FORMAT',
|
|
16
|
+
RESOURCE_TOO_LARGE: 'RESOURCE_TOO_LARGE',
|
|
17
|
+
ZDR_INCOMPATIBLE: 'ZDR_INCOMPATIBLE',
|
|
18
|
+
MODEL_NOT_FOUND: 'MODEL_NOT_FOUND',
|
|
19
|
+
JOB_FAILED: 'JOB_FAILED',
|
|
20
|
+
JOB_STILL_RUNNING: 'JOB_STILL_RUNNING',
|
|
21
|
+
INTERNAL: 'INTERNAL',
|
|
22
|
+
};
|
|
23
|
+
/** Build a structured MCP error result. */
|
|
24
|
+
export function toolError(code, message, details) {
|
|
25
|
+
const result = {
|
|
26
|
+
content: [{ type: 'text', text: message }],
|
|
27
|
+
isError: true,
|
|
28
|
+
_meta: { code },
|
|
29
|
+
};
|
|
30
|
+
if (details !== undefined)
|
|
31
|
+
result._meta.details = details;
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Convert a caught `unknown` error into a structured tool result. Preserves
|
|
36
|
+
* user-visible messages for known `Error` types and refuses to leak stack
|
|
37
|
+
* traces or raw objects.
|
|
38
|
+
*/
|
|
39
|
+
export function toolErrorFrom(code, err, prefix) {
|
|
40
|
+
const base = prefix ? `${prefix}: ` : '';
|
|
41
|
+
if (err instanceof Error)
|
|
42
|
+
return toolError(code, base + err.message);
|
|
43
|
+
if (typeof err === 'string')
|
|
44
|
+
return toolError(code, base + err);
|
|
45
|
+
return toolError(code, base + 'unknown error');
|
|
46
|
+
}
|
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: '
|
|
24
|
+
const server = new Server({ name: 'openrouter-multimodal-server', version: '3.0.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/logger.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stderr-bound JSON line logger. stdout is the MCP transport so logs MUST
|
|
3
|
+
* go to stderr or the client will choke. Output is one JSON object per line:
|
|
4
|
+
*
|
|
5
|
+
* {"ts":"2026-04-20T14:03:10.123Z","level":"info","msg":"job_submitted",
|
|
6
|
+
* "ctx":{"model":"google/veo-3.1","id":"vid_abc"}}
|
|
7
|
+
*
|
|
8
|
+
* Level is filtered by OPENROUTER_LOG_LEVEL (error|warn|info|debug,
|
|
9
|
+
* default info). Unknown values fall through to info.
|
|
10
|
+
*/
|
|
11
|
+
export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
|
|
12
|
+
/** Low-level write hook, replaceable in tests. */
|
|
13
|
+
export declare const _sink: {
|
|
14
|
+
write(line: string): void;
|
|
15
|
+
};
|
|
16
|
+
export declare function log(level: LogLevel, msg: string, ctx?: Record<string, unknown>): void;
|
|
17
|
+
export declare const logger: {
|
|
18
|
+
error: (msg: string, ctx?: Record<string, unknown>) => void;
|
|
19
|
+
warn: (msg: string, ctx?: Record<string, unknown>) => void;
|
|
20
|
+
info: (msg: string, ctx?: Record<string, unknown>) => void;
|
|
21
|
+
debug: (msg: string, ctx?: Record<string, unknown>) => void;
|
|
22
|
+
};
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const LEVEL_ORDER = {
|
|
2
|
+
error: 0,
|
|
3
|
+
warn: 1,
|
|
4
|
+
info: 2,
|
|
5
|
+
debug: 3,
|
|
6
|
+
};
|
|
7
|
+
function currentLevel() {
|
|
8
|
+
const raw = (process.env.OPENROUTER_LOG_LEVEL ?? '').toLowerCase();
|
|
9
|
+
if (raw === 'error' || raw === 'warn' || raw === 'info' || raw === 'debug')
|
|
10
|
+
return raw;
|
|
11
|
+
return 'info';
|
|
12
|
+
}
|
|
13
|
+
/** Low-level write hook, replaceable in tests. */
|
|
14
|
+
export const _sink = {
|
|
15
|
+
write(line) {
|
|
16
|
+
process.stderr.write(line + '\n');
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
export function log(level, msg, ctx) {
|
|
20
|
+
if (LEVEL_ORDER[level] > LEVEL_ORDER[currentLevel()])
|
|
21
|
+
return;
|
|
22
|
+
const record = {
|
|
23
|
+
ts: new Date().toISOString(),
|
|
24
|
+
level,
|
|
25
|
+
msg,
|
|
26
|
+
};
|
|
27
|
+
if (ctx)
|
|
28
|
+
record.ctx = ctx;
|
|
29
|
+
try {
|
|
30
|
+
_sink.write(JSON.stringify(record));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Fall back to a short-form record if `ctx` contains something unserializable.
|
|
34
|
+
_sink.write(JSON.stringify({
|
|
35
|
+
ts: new Date().toISOString(),
|
|
36
|
+
level,
|
|
37
|
+
msg,
|
|
38
|
+
ctx: { note: 'unserializable' },
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export const logger = {
|
|
43
|
+
error: (msg, ctx) => log('error', msg, ctx),
|
|
44
|
+
warn: (msg, ctx) => log('warn', msg, ctx),
|
|
45
|
+
info: (msg, ctx) => log('info', msg, ctx),
|
|
46
|
+
debug: (msg, ctx) => log('debug', msg, ctx),
|
|
47
|
+
};
|
package/dist/model-cache.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export interface OpenRouterModelRecord {
|
|
|
3
3
|
name?: string;
|
|
4
4
|
architecture?: {
|
|
5
5
|
input_modalities?: string[];
|
|
6
|
+
output_modalities?: string[];
|
|
6
7
|
};
|
|
7
8
|
context_length?: number;
|
|
8
9
|
[key: string]: unknown;
|
|
@@ -11,9 +12,16 @@ export declare class ModelCache {
|
|
|
11
12
|
private static instance;
|
|
12
13
|
private models;
|
|
13
14
|
private fetchedAt;
|
|
15
|
+
private inflight;
|
|
14
16
|
static getInstance(): ModelCache;
|
|
15
17
|
isValid(): boolean;
|
|
16
18
|
setModels(models: OpenRouterModelRecord[]): void;
|
|
19
|
+
/**
|
|
20
|
+
* Populate the cache using `fetcher` if stale, coalescing concurrent callers
|
|
21
|
+
* so only one request hits the upstream API per stale window. Callers that
|
|
22
|
+
* arrive while a populate is in flight await the same promise.
|
|
23
|
+
*/
|
|
24
|
+
ensureFresh(fetcher: () => Promise<OpenRouterModelRecord[]>): Promise<void>;
|
|
17
25
|
getAll(): OpenRouterModelRecord[];
|
|
18
26
|
get(id: string): OpenRouterModelRecord | null;
|
|
19
27
|
has(id: string): boolean;
|
|
@@ -22,6 +30,8 @@ export declare class ModelCache {
|
|
|
22
30
|
provider?: string;
|
|
23
31
|
capabilities?: {
|
|
24
32
|
vision?: boolean;
|
|
33
|
+
audio?: boolean;
|
|
34
|
+
video?: boolean;
|
|
25
35
|
};
|
|
26
36
|
limit?: number;
|
|
27
37
|
}): OpenRouterModelRecord[];
|
package/dist/model-cache.js
CHANGED
|
@@ -5,10 +5,12 @@ function getCacheTtlMs() {
|
|
|
5
5
|
const n = parseInt(raw, 10);
|
|
6
6
|
return Number.isFinite(n) && n > 0 ? n : 3600000;
|
|
7
7
|
}
|
|
8
|
+
const MAX_SEARCH_LIMIT = 50;
|
|
8
9
|
export class ModelCache {
|
|
9
10
|
static instance;
|
|
10
11
|
models = {};
|
|
11
12
|
fetchedAt = 0;
|
|
13
|
+
inflight = null;
|
|
12
14
|
static getInstance() {
|
|
13
15
|
return (ModelCache.instance ??= new ModelCache());
|
|
14
16
|
}
|
|
@@ -19,6 +21,27 @@ export class ModelCache {
|
|
|
19
21
|
this.models = Object.fromEntries(models.map((m) => [m.id, m]));
|
|
20
22
|
this.fetchedAt = Date.now();
|
|
21
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Populate the cache using `fetcher` if stale, coalescing concurrent callers
|
|
26
|
+
* so only one request hits the upstream API per stale window. Callers that
|
|
27
|
+
* arrive while a populate is in flight await the same promise.
|
|
28
|
+
*/
|
|
29
|
+
async ensureFresh(fetcher) {
|
|
30
|
+
if (this.isValid())
|
|
31
|
+
return;
|
|
32
|
+
if (this.inflight) {
|
|
33
|
+
await this.inflight;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.inflight = (async () => fetcher())();
|
|
37
|
+
try {
|
|
38
|
+
const models = await this.inflight;
|
|
39
|
+
this.setModels(models);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
this.inflight = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
22
45
|
getAll() {
|
|
23
46
|
return Object.values(this.models);
|
|
24
47
|
}
|
|
@@ -41,6 +64,13 @@ export class ModelCache {
|
|
|
41
64
|
if (params.capabilities?.vision) {
|
|
42
65
|
results = results.filter((m) => m.architecture?.input_modalities?.includes('image'));
|
|
43
66
|
}
|
|
44
|
-
|
|
67
|
+
if (params.capabilities?.audio) {
|
|
68
|
+
results = results.filter((m) => m.architecture?.input_modalities?.includes('audio'));
|
|
69
|
+
}
|
|
70
|
+
if (params.capabilities?.video) {
|
|
71
|
+
results = results.filter((m) => m.architecture?.input_modalities?.includes('video'));
|
|
72
|
+
}
|
|
73
|
+
const limit = Math.min(Math.max(1, params.limit ?? 10), MAX_SEARCH_LIMIT);
|
|
74
|
+
return results.slice(0, limit);
|
|
45
75
|
}
|
|
46
76
|
}
|
package/dist/openrouter-api.d.ts
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
import type { OpenRouterModelRecord } from './model-cache.js';
|
|
2
|
+
declare function parseRetryAfter(headerValue: string | null): number | null;
|
|
3
|
+
declare function backoffWithJitter(attempt: number, retryAfterMs: number | null): number;
|
|
4
|
+
/**
|
|
5
|
+
* fetch() wrapper with retries on 429 / 5xx / network error.
|
|
6
|
+
*
|
|
7
|
+
* A fresh `AbortSignal.timeout(timeoutMs)` is created per attempt so retries
|
|
8
|
+
* each get a full timeout budget. Backoff honors `Retry-After` (seconds or
|
|
9
|
+
* HTTP-date) and applies jitter to avoid thundering-herd synchronization.
|
|
10
|
+
*/
|
|
11
|
+
declare function fetchWithRetry(url: string, init: Omit<RequestInit, 'signal'>, { retries, timeoutMs }?: {
|
|
12
|
+
retries?: number;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}): Promise<Response>;
|
|
2
15
|
export declare class OpenRouterAPIClient {
|
|
3
16
|
private apiKey;
|
|
4
17
|
constructor(apiKey: string);
|
|
18
|
+
private authHeaders;
|
|
5
19
|
getModels(): Promise<OpenRouterModelRecord[]>;
|
|
20
|
+
/** Submit a video-generation job. Returns the `{ id, polling_url, status }` envelope. */
|
|
21
|
+
submitVideoJob(body: Record<string, unknown>): Promise<VideoJobEnvelope>;
|
|
22
|
+
/** Poll a submitted video-generation job by id. */
|
|
23
|
+
pollVideoJob(id: string): Promise<VideoJobStatus>;
|
|
24
|
+
/**
|
|
25
|
+
* Download the generated video binary. Returns `{ buffer, contentType }`.
|
|
26
|
+
* This intentionally does NOT go through our SSRF-guarded `fetchHttpResource`
|
|
27
|
+
* because the URL is always OpenRouter itself (trusted origin) — and it can
|
|
28
|
+
* return arbitrarily large bodies that the caller bounds via
|
|
29
|
+
* `OPENROUTER_VIDEO_MAX_DOWNLOAD_BYTES`.
|
|
30
|
+
*/
|
|
31
|
+
downloadVideoContent(id: string, index?: number, maxBytes?: number): Promise<{
|
|
32
|
+
buffer: Buffer;
|
|
33
|
+
contentType: string | null;
|
|
34
|
+
}>;
|
|
6
35
|
}
|
|
36
|
+
export interface VideoJobEnvelope {
|
|
37
|
+
id: string;
|
|
38
|
+
status?: VideoJobStatusName;
|
|
39
|
+
polling_url?: string;
|
|
40
|
+
[key: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
export type VideoJobStatusName = 'pending' | 'queued' | 'processing' | 'completed' | 'failed';
|
|
43
|
+
export interface VideoJobStatus {
|
|
44
|
+
id: string;
|
|
45
|
+
status: VideoJobStatusName | string;
|
|
46
|
+
unsigned_urls?: string[];
|
|
47
|
+
error?: {
|
|
48
|
+
message?: string;
|
|
49
|
+
code?: string;
|
|
50
|
+
} | string;
|
|
51
|
+
usage?: Record<string, unknown>;
|
|
52
|
+
progress?: number;
|
|
53
|
+
[key: string]: unknown;
|
|
54
|
+
}
|
|
55
|
+
export declare const _internals: {
|
|
56
|
+
parseRetryAfter: typeof parseRetryAfter;
|
|
57
|
+
backoffWithJitter: typeof backoffWithJitter;
|
|
58
|
+
fetchWithRetry: typeof fetchWithRetry;
|
|
59
|
+
};
|
|
60
|
+
export {};
|