@stabgan/openrouter-mcp-multimodal 4.6.2 → 4.8.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 +108 -37
- package/dist/errors.d.ts +5 -20
- package/dist/errors.js +1 -10
- package/dist/index.js +7 -1
- package/dist/logger.js +54 -24
- package/dist/model-cache.d.ts +13 -0
- package/dist/model-cache.js +61 -5
- package/dist/openrouter-api.d.ts +14 -15
- package/dist/openrouter-api.js +68 -22
- package/dist/openrouter-openai-client.d.ts +9 -0
- package/dist/openrouter-openai-client.js +15 -0
- package/dist/tool-definitions.d.ts +24 -0
- package/dist/tool-definitions.js +283 -170
- package/dist/tool-descriptions.js +23 -15
- package/dist/tool-handlers/analyze-audio.js +4 -1
- package/dist/tool-handlers/analyze-image.js +10 -5
- package/dist/tool-handlers/analyze-video.js +9 -5
- package/dist/tool-handlers/async-chat.d.ts +17 -0
- package/dist/tool-handlers/async-chat.js +112 -31
- package/dist/tool-handlers/audio-utils.d.ts +19 -4
- package/dist/tool-handlers/audio-utils.js +170 -16
- package/dist/tool-handlers/cache.d.ts +3 -3
- package/dist/tool-handlers/cache.js +56 -4
- package/dist/tool-handlers/chat-completion.js +16 -7
- package/dist/tool-handlers/chat-request.d.ts +3 -0
- package/dist/tool-handlers/chat-request.js +28 -0
- package/dist/tool-handlers/completion-utils.d.ts +5 -11
- package/dist/tool-handlers/completion-utils.js +76 -47
- package/dist/tool-handlers/fetch-utils.d.ts +14 -0
- package/dist/tool-handlers/fetch-utils.js +331 -68
- package/dist/tool-handlers/generate-audio.d.ts +6 -51
- package/dist/tool-handlers/generate-audio.js +30 -76
- package/dist/tool-handlers/generate-image-dedicated.d.ts +2 -12
- package/dist/tool-handlers/generate-image-dedicated.js +79 -28
- package/dist/tool-handlers/generate-image.d.ts +3 -46
- package/dist/tool-handlers/generate-image.js +26 -43
- package/dist/tool-handlers/generate-video.d.ts +4 -3
- package/dist/tool-handlers/generate-video.js +51 -50
- package/dist/tool-handlers/get-model-info.js +1 -1
- package/dist/tool-handlers/health-check.js +39 -15
- package/dist/tool-handlers/image-utils.js +2 -2
- package/dist/tool-handlers/openrouter-errors.d.ts +2 -0
- package/dist/tool-handlers/openrouter-errors.js +138 -31
- package/dist/tool-handlers/path-safety.d.ts +6 -0
- package/dist/tool-handlers/path-safety.js +77 -14
- package/dist/tool-handlers/path-utils.d.ts +4 -0
- package/dist/tool-handlers/path-utils.js +20 -0
- package/dist/tool-handlers/provider-routing.d.ts +2 -0
- package/dist/tool-handlers/provider-routing.js +10 -0
- package/dist/tool-handlers/rerank.d.ts +1 -4
- package/dist/tool-handlers/rerank.js +43 -14
- package/dist/tool-handlers/search-models.js +3 -3
- package/dist/tool-handlers/speech-to-text.d.ts +1 -0
- package/dist/tool-handlers/speech-to-text.js +23 -56
- package/dist/tool-handlers/text-to-speech.d.ts +2 -12
- package/dist/tool-handlers/text-to-speech.js +29 -22
- package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
- package/dist/tool-handlers/tool-result-payload.js +98 -0
- package/dist/tool-handlers/validate-model.js +1 -1
- package/dist/tool-handlers.d.ts +9 -0
- package/dist/tool-handlers.js +19 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -3
package/README.md
CHANGED
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
<a href="#tools">Tools</a> ·
|
|
32
32
|
<a href="#examples">Examples</a> ·
|
|
33
33
|
<a href="#security">Security</a> ·
|
|
34
|
+
<a href="#troubleshooting">Troubleshooting</a> ·
|
|
34
35
|
<a href="#development">Development</a> ·
|
|
36
|
+
<a href="#releasing">Releasing</a> ·
|
|
35
37
|
<a href="#faq">FAQ</a>
|
|
36
38
|
</p>
|
|
37
39
|
|
|
@@ -51,7 +53,7 @@ Unlike text-only MCP servers, one install covers the **full multimodal surface**
|
|
|
51
53
|
| **Video** | `analyze_video`, `generate_video`, `generate_video_from_image`, `get_video_status` | Clip understanding, Veo 3.1 / Seedance 2.0 / Wan 2.7 generation with progress notifications |
|
|
52
54
|
| **Catalog** | `search_models`, `get_model_info`, `validate_model`, `rerank_documents`, `health_check` | Model discovery, validation, reranking, ops health |
|
|
53
55
|
|
|
54
|
-
**Production hardening:** input/output path sandboxes (including analyze\_\* local files as of v4.5.2), SSRF guards, structured errors with `_meta.code`, MCP 2025-06-18 structured outputs, tool icons (2025-11-25), async video progress notifications, and **
|
|
56
|
+
**Production hardening:** input/output path sandboxes (including analyze\_\* local files as of v4.5.2), SSRF guards, structured errors with `_meta.code`, MCP 2025-06-18 structured outputs, tool icons (2025-11-25), async video progress notifications, and **1000+** automated tests (unit, mock, regression, and live integration).
|
|
55
57
|
|
|
56
58
|
## Quick start
|
|
57
59
|
|
|
@@ -64,7 +66,16 @@ export OPENROUTER_API_KEY=sk-or-v1-...
|
|
|
64
66
|
npx -y @stabgan/openrouter-mcp-multimodal
|
|
65
67
|
```
|
|
66
68
|
|
|
67
|
-
**3. Add to your MCP client**
|
|
69
|
+
**3. Add to your MCP client** — copy one JSON block from [Install](#install) into your client config:
|
|
70
|
+
|
|
71
|
+
| Client | Config location |
|
|
72
|
+
| :----------------- | :-------------------------------------------------------------------------------------------------------------------------------- |
|
|
73
|
+
| **Cursor** | Project: `.cursor/mcp.json` · User: Cursor Settings → MCP |
|
|
74
|
+
| **Claude Desktop** | macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` · Windows: `%APPDATA%\Claude\claude_desktop_config.json` |
|
|
75
|
+
| **VS Code** | `.vscode/mcp.json` (workspace) or User Settings → MCP |
|
|
76
|
+
| **Windsurf** | Windsurf Settings → MCP (same `mcpServers` JSON shape as Cursor) |
|
|
77
|
+
|
|
78
|
+
Use the `mcpServers` object from [Manual config](#manual-config) below.
|
|
68
79
|
|
|
69
80
|
> **No credits required to start.** Free models such as `google/gemma-4-26b-a4b-it:free` work for chat and vision. Video/audio generation typically needs credits.
|
|
70
81
|
|
|
@@ -72,23 +83,23 @@ npx -y @stabgan/openrouter-mcp-multimodal
|
|
|
72
83
|
|
|
73
84
|
MCP servers are distributed through several packaging models. **This server is implemented in Node.js/TypeScript**; the table below maps each ecosystem method to how you run it here.
|
|
74
85
|
|
|
75
|
-
| Method
|
|
76
|
-
|
|
|
77
|
-
| **[npx](#
|
|
78
|
-
| **[uvx / pipx](#
|
|
79
|
-
| **[npm global](#
|
|
80
|
-
| **[node (local)](#
|
|
81
|
-
| **[Docker Hub](#
|
|
82
|
-
| **[GHCR](#
|
|
83
|
-
| **[Smithery CLI](#smithery)**
|
|
84
|
-
| **[MCP Registry](#mcp-registry)**
|
|
85
|
-
| **[One-click deeplinks](#one-click)**
|
|
86
|
-
| **[Claude Code CLI](#claude-code-cli)**
|
|
87
|
-
| **[MCP Inspector](#mcp-inspector)**
|
|
88
|
-
| **Windows `cmd /c npx`**
|
|
89
|
-
| pip / uv (direct)
|
|
90
|
-
| DXT desktop extensions
|
|
91
|
-
| Remote HTTP / SSE
|
|
86
|
+
| Method | Runtime | Best for | This server |
|
|
87
|
+
| :-------------------------------------- | :------------------------------- | :------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------- |
|
|
88
|
+
| **[npx](#manual-config)** | Node.js 20+ | Most MCP clients (default) | ✅ `@stabgan/openrouter-mcp-multimodal` |
|
|
89
|
+
| **[uvx / pipx](#manual-config)** | Python 3.10+ **and** Node.js 20+ | Python-first workflows, same pattern as PyPI MCP servers | ✅ [`mcp-server-openrouter-multimodal`](https://pypi.org/project/mcp-server-openrouter-multimodal/) |
|
|
90
|
+
| **[npm global](#manual-config)** | Node.js 20+ | Pin a version without re-downloading | ✅ |
|
|
91
|
+
| **[node (local)](#manual-config)** | Node.js 20+ | Contributors / air-gapped builds | ✅ |
|
|
92
|
+
| **[Docker Hub](#manual-config)** | Docker | Isolation, no Node on host | ✅ `stabgan/openrouter-mcp-multimodal` |
|
|
93
|
+
| **[GHCR](#manual-config)** | Docker | GitHub-native OCI pulls | ✅ `ghcr.io/stabgan/openrouter-mcp-multimodal` |
|
|
94
|
+
| **[Smithery CLI](#smithery)** | Node.js (via installer) | Interactive install into Claude/Cursor/etc. | ✅ |
|
|
95
|
+
| **[MCP Registry](#mcp-registry)** | npm or OCI | Official discovery (`io.github.stabgan/openrouter-multimodal`) | ✅ [listing](https://registry.modelcontextprotocol.io/servers/io.github.stabgan/openrouter-multimodal) |
|
|
96
|
+
| **[One-click deeplinks](#one-click)** | Node.js | Cursor, VS Code, Kiro | ✅ |
|
|
97
|
+
| **[Claude Code CLI](#claude-code-cli)** | Node.js | Terminal-first Claude Code users | ✅ |
|
|
98
|
+
| **[MCP Inspector](#mcp-inspector)** | Node.js | Debug / list tools locally | ✅ |
|
|
99
|
+
| **Windows `cmd /c npx`** | Node.js | Claude Desktop / Cursor when `npx` not on GUI PATH | ✅ [see below](#windows-npx) |
|
|
100
|
+
| pip / uv (direct) | — | Native Python MCP servers only | — use **uvx** row above |
|
|
101
|
+
| DXT desktop extensions | — | Bundled Claude Desktop `.dxt` | not yet |
|
|
102
|
+
| Remote HTTP / SSE | — | Hosted Smithery / Cloudflare endpoints | via [Smithery](https://smithery.ai/server/@stabgan/openrouter-mcp-multimodal) |
|
|
92
103
|
|
|
93
104
|
> **uvx vs npx:** In the MCP ecosystem, **`npx` runs npm (Node) packages** and **`uvx` runs PyPI (Python) packages**. Because this server is Node-based, `uvx` uses a thin [Python launcher](./python/) that execs `npx -y @stabgan/openrouter-mcp-multimodal` — you still need Node installed.
|
|
94
105
|
|
|
@@ -129,7 +140,7 @@ npx -y @stabgan/openrouter-mcp-multimodal
|
|
|
129
140
|
}
|
|
130
141
|
```
|
|
131
142
|
|
|
132
|
-
Pin a release: `"args": ["-y", "@stabgan/openrouter-mcp-multimodal@4.
|
|
143
|
+
Pin a release: `"args": ["-y", "@stabgan/openrouter-mcp-multimodal@4.8.0"]`
|
|
133
144
|
|
|
134
145
|
</details>
|
|
135
146
|
|
|
@@ -141,7 +152,7 @@ Install [uv](https://docs.astral.sh/uv/getting-started/installation/) (includes
|
|
|
141
152
|
```bash
|
|
142
153
|
export OPENROUTER_API_KEY=sk-or-v1-...
|
|
143
154
|
uvx mcp-server-openrouter-multimodal
|
|
144
|
-
# pin npm version: OPENROUTER_MCP_NPM_VERSION=4.
|
|
155
|
+
# pin npm version: OPENROUTER_MCP_NPM_VERSION=4.8.0 uvx mcp-server-openrouter-multimodal
|
|
145
156
|
```
|
|
146
157
|
|
|
147
158
|
```json
|
|
@@ -160,7 +171,7 @@ uvx mcp-server-openrouter-multimodal
|
|
|
160
171
|
|
|
161
172
|
**pipx equivalent:** `pipx run mcp-server-openrouter-multimodal`
|
|
162
173
|
|
|
163
|
-
Optional: `OPENROUTER_MCP_NPM_VERSION=4.
|
|
174
|
+
Optional: `OPENROUTER_MCP_NPM_VERSION=4.8.0` pins the underlying npm package.
|
|
164
175
|
|
|
165
176
|
</details>
|
|
166
177
|
|
|
@@ -241,7 +252,7 @@ Use `-i` (interactive stdio). Avoid `-t` (TTY corrupts MCP framing on some hosts
|
|
|
241
252
|
|
|
242
253
|
```bash
|
|
243
254
|
docker run --rm -i -e OPENROUTER_API_KEY=sk-or-v1-... \
|
|
244
|
-
ghcr.io/stabgan/openrouter-mcp-multimodal:4.
|
|
255
|
+
ghcr.io/stabgan/openrouter-mcp-multimodal:4.8.0
|
|
245
256
|
```
|
|
246
257
|
|
|
247
258
|
```json
|
|
@@ -255,7 +266,7 @@ docker run --rm -i -e OPENROUTER_API_KEY=sk-or-v1-... \
|
|
|
255
266
|
"-i",
|
|
256
267
|
"-e",
|
|
257
268
|
"OPENROUTER_API_KEY=sk-or-v1-...",
|
|
258
|
-
"ghcr.io/stabgan/openrouter-mcp-multimodal:
|
|
269
|
+
"ghcr.io/stabgan/openrouter-mcp-multimodal:4.8.0"
|
|
259
270
|
]
|
|
260
271
|
}
|
|
261
272
|
}
|
|
@@ -378,6 +389,26 @@ If still failing, use the full path from `where npx` as the command.
|
|
|
378
389
|
|
|
379
390
|
Errors use a closed `_meta.code` taxonomy: `INVALID_INPUT` · `UNSAFE_PATH` · `UPSTREAM_*` · `MODEL_NOT_FOUND` · `JOB_STILL_RUNNING` · and more.
|
|
380
391
|
|
|
392
|
+
### Binary tool results (v4.7.0+)
|
|
393
|
+
|
|
394
|
+
Generate tools (`generate_image`, `generate_image_dedicated`, `generate_audio`, `text_to_speech`, `generate_video`, `generate_video_from_image`, `get_video_status`) return image, audio, or video bytes. As of **4.7.0** the behavior is explicit:
|
|
395
|
+
|
|
396
|
+
| `save_path` | Tool result |
|
|
397
|
+
| :---------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
398
|
+
| **Set** | **Text pointer only** — e.g. `Image saved to: out.png (… bytes, image/png)` plus `_meta.save_path`. **No inline base64** (avoids duplicating large payloads in the MCP channel). |
|
|
399
|
+
| **Unset, under byte ceiling** | Inline media block **and** summary text (images/audio use MCP `image` / `audio` types; video uses MCP `resource` blocks). |
|
|
400
|
+
| **Unset, over ceiling** | Text only with a hint to pass `save_path`. |
|
|
401
|
+
|
|
402
|
+
Default inline ceilings (override per kind or globally):
|
|
403
|
+
|
|
404
|
+
| Kind | Default | Env vars (precedence: per-kind → global) |
|
|
405
|
+
| :---- | :------ | :------------------------------------------------------------------ |
|
|
406
|
+
| Image | 1 MiB | `OPENROUTER_IMAGE_INLINE_MAX_BYTES` → `OPENROUTER_INLINE_MAX_BYTES` |
|
|
407
|
+
| Audio | 1 MiB | `OPENROUTER_AUDIO_INLINE_MAX_BYTES` → `OPENROUTER_INLINE_MAX_BYTES` |
|
|
408
|
+
| Video | 10 MiB | `OPENROUTER_VIDEO_INLINE_MAX_BYTES` → `OPENROUTER_INLINE_MAX_BYTES` |
|
|
409
|
+
|
|
410
|
+
If you previously relied on **both** a saved file **and** inline media in the same tool result, read the file from `_meta.save_path` (or omit `save_path` to get inline media when under the ceiling).
|
|
411
|
+
|
|
381
412
|
## Examples
|
|
382
413
|
|
|
383
414
|
### Chat (free model)
|
|
@@ -436,32 +467,40 @@ Errors use a closed `_meta.code` taxonomy: `INVALID_INPUT` · `UNSAFE_PATH` · `
|
|
|
436
467
|
|
|
437
468
|
If the job is still running when `max_wait_ms` elapses, the response succeeds with `_meta.code: JOB_STILL_RUNNING` and a `video_id` — call `get_video_status` to resume. **This is not an error.**
|
|
438
469
|
|
|
470
|
+
With `save_path` set (as above), the result is a **text pointer** to the saved file once complete — not inline video. See [Binary tool results](#binary-tool-results-v470).
|
|
471
|
+
|
|
439
472
|
More examples: [docs/plans/tool-description-improvement.md](./docs/plans/tool-description-improvement.md)
|
|
440
473
|
|
|
441
474
|
## Security
|
|
442
475
|
|
|
443
|
-
- **Input path sandbox** — `analyze_*` and reference images must stay inside `OPENROUTER_INPUT_DIR`
|
|
476
|
+
- **Input path sandbox** — local paths on `analyze_*` and reference images must stay inside `OPENROUTER_INPUT_DIR` (falls back to `OPENROUTER_OUTPUT_DIR`, then `cwd`)
|
|
444
477
|
- **Output path sandbox** — `save_path` must stay inside `OPENROUTER_OUTPUT_DIR`
|
|
478
|
+
- **Async job reads** — `get_chat_completion_status` resolves disk paths only under `OPENROUTER_OUTPUT_DIR/openrouter-jobs/` (4.7.0+)
|
|
445
479
|
- **SSRF protection** — private/reserved IPs blocked on URL fetches
|
|
446
480
|
- **Untrusted content** — analyze outputs tagged `_meta.content_is_untrusted: true`
|
|
447
481
|
|
|
448
482
|
Override sandboxes only with `OPENROUTER_ALLOW_UNSAFE_PATHS=1` (discouraged).
|
|
449
483
|
|
|
484
|
+
Report vulnerabilities: **[SECURITY.md](./SECURITY.md)** (private disclosure — do not file public issues for exploits).
|
|
485
|
+
|
|
450
486
|
## Configuration
|
|
451
487
|
|
|
452
488
|
<details>
|
|
453
489
|
<summary><strong>Environment variables</strong></summary>
|
|
454
490
|
|
|
455
|
-
| Variable
|
|
456
|
-
|
|
|
457
|
-
| `OPENROUTER_API_KEY`
|
|
458
|
-
| `OPENROUTER_DEFAULT_MODEL`
|
|
459
|
-
| `
|
|
460
|
-
| `
|
|
461
|
-
| `
|
|
462
|
-
| `
|
|
491
|
+
| Variable | Required | Default | Description |
|
|
492
|
+
| :---------------------------------- | :------: | :------------------------------------ | :---------------------------------- |
|
|
493
|
+
| `OPENROUTER_API_KEY` | **Yes** | — | OpenRouter API key |
|
|
494
|
+
| `OPENROUTER_DEFAULT_MODEL` | No | `nvidia/nemotron-nano-12b-v2-vl:free` | Default when tools omit `model` |
|
|
495
|
+
| `OPENROUTER_OUTPUT_DIR` | No | `cwd` | Sandbox root for `save_path` |
|
|
496
|
+
| `OPENROUTER_INPUT_DIR` | No | `OUTPUT_DIR` or `cwd` | Sandbox root for local input files |
|
|
497
|
+
| `OPENROUTER_INLINE_MAX_BYTES` | No | `1048576` (image/audio) | Global inline media ceiling |
|
|
498
|
+
| `OPENROUTER_IMAGE_INLINE_MAX_BYTES` | No | falls back to global | Per-kind inline ceiling |
|
|
499
|
+
| `OPENROUTER_AUDIO_INLINE_MAX_BYTES` | No | falls back to global | Per-kind inline ceiling |
|
|
500
|
+
| `OPENROUTER_VIDEO_INLINE_MAX_BYTES` | No | `10485760` | Video inline ceiling |
|
|
501
|
+
| `OPENROUTER_LOG_LEVEL` | No | `info` | `error` / `warn` / `info` / `debug` |
|
|
463
502
|
|
|
464
|
-
See [`.env.example`](./.env.example) for the full list (provider routing,
|
|
503
|
+
See [`.env.example`](./.env.example) for the full list (provider routing, fetch limits, caching, video polling, async jobs, integration-test overrides).
|
|
465
504
|
|
|
466
505
|
</details>
|
|
467
506
|
|
|
@@ -479,7 +518,7 @@ npm run build
|
|
|
479
518
|
|
|
480
519
|
| Command | What it runs |
|
|
481
520
|
| :------------------------- | :--------------------------------------------------------- |
|
|
482
|
-
| `npm test` | **
|
|
521
|
+
| `npm test` | **1018** unit + mock tests (no API key, <20s) |
|
|
483
522
|
| `npm run test:regression` | Security + schema regression guards |
|
|
484
523
|
| `npm run test:integration` | **16** live OpenRouter scenarios (**requires** `.env` key) |
|
|
485
524
|
| `npm run test:e2e` | Full MCP stdio smoke (`scripts/live-e2e.mjs`) |
|
|
@@ -492,8 +531,38 @@ Mock tests live under `src/__tests__/mock/` and cover handlers, path sandboxes,
|
|
|
492
531
|
```bash
|
|
493
532
|
npm run lint
|
|
494
533
|
npm run format:check
|
|
534
|
+
npm run version:check # package.json vs src/version.ts, server.json, pyproject.toml
|
|
495
535
|
```
|
|
496
536
|
|
|
537
|
+
## Releasing
|
|
538
|
+
|
|
539
|
+
Published artifacts (**npm**, **PyPI/uvx**, **Docker**, **GHCR**) all ship from the **same semver** on a git tag (`vX.Y.Z`). Pushing to `main` runs tests but does **not** publish to npm or PyPI.
|
|
540
|
+
|
|
541
|
+
**Normal flow:** merge conventional commits to `main` → [Release Please](https://github.com/googleapis/release-please) opens a Release PR → merge it → tag is created → CI publishes everywhere.
|
|
542
|
+
|
|
543
|
+
**Manual flow:** bump all version files → `npm run version:check` → `npm run ci` + smoke tests → commit → `git tag vX.Y.Z` → `git push origin vX.Y.Z`.
|
|
544
|
+
|
|
545
|
+
Full checklist, file list, CI secrets, and agent instructions:
|
|
546
|
+
|
|
547
|
+
- **[`docs/RELEASING.md`](docs/RELEASING.md)** — maintainer release guide
|
|
548
|
+
- **[`AGENTS.md`](AGENTS.md)** — quick reference for AI agents
|
|
549
|
+
|
|
550
|
+
## Troubleshooting
|
|
551
|
+
|
|
552
|
+
| Symptom | Likely cause | Fix |
|
|
553
|
+
| :---------------------------------------------------------- | :--------------------------------------- | :-------------------------------------------------------------------------------------------------------------- |
|
|
554
|
+
| Server exits immediately / `OPENROUTER_API_KEY is required` | Missing or empty API key | Set `OPENROUTER_API_KEY` in client `env` or shell — get one at [openrouter.ai/keys](https://openrouter.ai/keys) |
|
|
555
|
+
| `_meta.code: INVALID_CREDENTIALS` or HTTP 401 | Bad or revoked key | Regenerate at [openrouter.ai/keys](https://openrouter.ai/keys); restart the MCP client |
|
|
556
|
+
| `_meta.code: MODEL_NOT_FOUND` | Typo or retired model ID | Run `search_models` or `validate_model`; check [openrouter.ai/models](https://openrouter.ai/models) |
|
|
557
|
+
| HTTP 402 / insufficient credits | Paid model or generation on zero balance | Add credits at [openrouter.ai/credits](https://openrouter.ai/credits) or use a `:free` model |
|
|
558
|
+
| `_meta.code: UPSTREAM_HTTP` with 429 | Rate limit | Wait for `_meta.retry_after_seconds` if present; reduce concurrency |
|
|
559
|
+
| `_meta.code: UNSAFE_PATH` | Local path outside sandbox | Put files under `OPENROUTER_INPUT_DIR` or set `OPENROUTER_OUTPUT_DIR` wider; see [Security](#security) |
|
|
560
|
+
| `npx` not found (Windows GUI apps) | GUI `PATH` differs from terminal | Use the [Windows npx](#windows-npx) `cmd /c` wrapper |
|
|
561
|
+
| No inline image/audio after upgrade | **v4.7.0** with `save_path` set | Expected — result is text + `_meta.save_path` only; omit `save_path` or read the saved file |
|
|
562
|
+
| MCP client shows stale tool list | Client cache | Restart MCP / reload window after upgrading the package pin |
|
|
563
|
+
|
|
564
|
+
Structured errors include `_meta.suggestions` with agent-oriented next steps when available.
|
|
565
|
+
|
|
497
566
|
## FAQ
|
|
498
567
|
|
|
499
568
|
### Do I need paid OpenRouter credits?
|
|
@@ -510,7 +579,7 @@ This server adds MCP tool schemas, security sandboxes, error taxonomy, model cac
|
|
|
510
579
|
|
|
511
580
|
### Where is the security advisory for path traversal?
|
|
512
581
|
|
|
513
|
-
Fixed in 4.5.2+ — see [GHSA-3q7p-736f-x44v](https://github.com/stabgan/openrouter-mcp-multimodal/security/advisories/GHSA-3q7p-736f-x44v) and `docs/solutions/security-issues/`.
|
|
582
|
+
Fixed in 4.5.2+ — see [GHSA-3q7p-736f-x44v](https://github.com/stabgan/openrouter-mcp-multimodal/security/advisories/GHSA-3q7p-736f-x44v), [`SECURITY.md`](./SECURITY.md), and `docs/solutions/security-issues/`.
|
|
514
583
|
|
|
515
584
|
## Compatibility
|
|
516
585
|
|
|
@@ -522,4 +591,6 @@ Apache 2.0 — see [LICENSE](./LICENSE).
|
|
|
522
591
|
|
|
523
592
|
## Contributing
|
|
524
593
|
|
|
525
|
-
Issues and PRs welcome. For large changes, open an issue first.
|
|
594
|
+
Issues and PRs welcome. For large changes, open an issue first.
|
|
595
|
+
|
|
596
|
+
Before submitting: run **`npm run ci`**. Use [Conventional Commits](https://www.conventionalcommits.org/) (`fix:`, `feat:`, etc.) so [Release Please](docs/RELEASING.md) can cut the next release. See **[`docs/RELEASING.md`](docs/RELEASING.md)** if you need to ship a version.
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Closed error-code taxonomy for MCP tool responses.
|
|
3
|
-
* Every handler uses `toolError()` so clients can switch on `_meta.code`.
|
|
4
|
-
*/
|
|
5
1
|
export declare const ErrorCode: {
|
|
6
2
|
readonly INVALID_INPUT: "INVALID_INPUT";
|
|
3
|
+
readonly INVALID_CREDENTIALS: "INVALID_CREDENTIALS";
|
|
7
4
|
readonly UNSAFE_PATH: "UNSAFE_PATH";
|
|
8
5
|
readonly UPSTREAM_HTTP: "UPSTREAM_HTTP";
|
|
9
6
|
readonly UPSTREAM_TIMEOUT: "UPSTREAM_TIMEOUT";
|
|
@@ -17,35 +14,23 @@ export declare const ErrorCode: {
|
|
|
17
14
|
readonly INTERNAL: "INTERNAL";
|
|
18
15
|
};
|
|
19
16
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
20
|
-
export
|
|
17
|
+
export type ToolErrorMeta = {
|
|
21
18
|
code: ErrorCode;
|
|
22
19
|
details?: Record<string, unknown>;
|
|
23
|
-
/** Optional next steps for the agent (e.g. "Wait and retry"). */
|
|
24
20
|
suggestions?: string[];
|
|
25
|
-
/**
|
|
26
|
-
* For rate-limit / backoff errors, the number of seconds the caller
|
|
27
|
-
* should wait before retrying. Derived from `Retry-After` headers when
|
|
28
|
-
* available.
|
|
29
|
-
*/
|
|
30
21
|
retry_after_seconds?: number;
|
|
31
|
-
}
|
|
32
|
-
export
|
|
22
|
+
} & Record<string, unknown>;
|
|
23
|
+
export type ToolErrorResult = {
|
|
33
24
|
content: Array<{
|
|
34
25
|
type: 'text';
|
|
35
26
|
text: string;
|
|
36
27
|
}>;
|
|
37
28
|
isError: true;
|
|
38
29
|
_meta: ToolErrorMeta;
|
|
39
|
-
}
|
|
30
|
+
} & Record<string, unknown>;
|
|
40
31
|
export interface ToolErrorOptions {
|
|
41
32
|
suggestions?: string[];
|
|
42
33
|
retry_after_seconds?: number;
|
|
43
34
|
}
|
|
44
|
-
/** Build a structured MCP error result. */
|
|
45
35
|
export declare function toolError(code: ErrorCode, message: string, details?: Record<string, unknown>, opts?: ToolErrorOptions): ToolErrorResult;
|
|
46
|
-
/**
|
|
47
|
-
* Convert a caught `unknown` error into a structured tool result. Preserves
|
|
48
|
-
* user-visible messages for known `Error` types and refuses to leak stack
|
|
49
|
-
* traces or raw objects.
|
|
50
|
-
*/
|
|
51
36
|
export declare function toolErrorFrom(code: ErrorCode, err: unknown, prefix?: string, opts?: ToolErrorOptions): ToolErrorResult;
|
package/dist/errors.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Closed error-code taxonomy for MCP tool responses.
|
|
3
|
-
* Every handler uses `toolError()` so clients can switch on `_meta.code`.
|
|
4
|
-
*/
|
|
5
1
|
export const ErrorCode = {
|
|
6
2
|
INVALID_INPUT: 'INVALID_INPUT',
|
|
3
|
+
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
|
|
7
4
|
UNSAFE_PATH: 'UNSAFE_PATH',
|
|
8
5
|
UPSTREAM_HTTP: 'UPSTREAM_HTTP',
|
|
9
6
|
UPSTREAM_TIMEOUT: 'UPSTREAM_TIMEOUT',
|
|
@@ -16,7 +13,6 @@ export const ErrorCode = {
|
|
|
16
13
|
JOB_STILL_RUNNING: 'JOB_STILL_RUNNING',
|
|
17
14
|
INTERNAL: 'INTERNAL',
|
|
18
15
|
};
|
|
19
|
-
/** Build a structured MCP error result. */
|
|
20
16
|
export function toolError(code, message, details, opts) {
|
|
21
17
|
const meta = { code };
|
|
22
18
|
if (details !== undefined)
|
|
@@ -32,11 +28,6 @@ export function toolError(code, message, details, opts) {
|
|
|
32
28
|
_meta: meta,
|
|
33
29
|
};
|
|
34
30
|
}
|
|
35
|
-
/**
|
|
36
|
-
* Convert a caught `unknown` error into a structured tool result. Preserves
|
|
37
|
-
* user-visible messages for known `Error` types and refuses to leak stack
|
|
38
|
-
* traces or raw objects.
|
|
39
|
-
*/
|
|
40
31
|
export function toolErrorFrom(code, err, prefix, opts) {
|
|
41
32
|
const base = prefix ? `${prefix}: ` : '';
|
|
42
33
|
if (err instanceof Error)
|
package/dist/index.js
CHANGED
|
@@ -43,9 +43,15 @@ const server = new Server({
|
|
|
43
43
|
}, { capabilities: { tools: {} } });
|
|
44
44
|
server.onerror = (error) => logFatal('mcpError', error);
|
|
45
45
|
new ToolHandlers(server, apiKey, defaultModel);
|
|
46
|
-
|
|
46
|
+
async function shutdown() {
|
|
47
47
|
await server.close();
|
|
48
48
|
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
process.on('SIGINT', () => {
|
|
51
|
+
void shutdown();
|
|
52
|
+
});
|
|
53
|
+
process.on('SIGTERM', () => {
|
|
54
|
+
void shutdown();
|
|
49
55
|
});
|
|
50
56
|
// Stdin may arrive as strings on some MCP hosts; re-wrap as raw Buffers for the SDK.
|
|
51
57
|
const stdinStream = process.stdin;
|
package/dist/logger.js
CHANGED
|
@@ -4,40 +4,80 @@ const LEVEL_ORDER = {
|
|
|
4
4
|
info: 2,
|
|
5
5
|
debug: 3,
|
|
6
6
|
};
|
|
7
|
+
const SENSITIVE_KEY = /^(authorization|api[_-]?key|bearer|token|secret|password)$/i;
|
|
8
|
+
const SK_OR_KEY = /sk-or-v\d+-[\w-]+/gi;
|
|
9
|
+
const BEARER = /Bearer\s+\S+/gi;
|
|
10
|
+
const DATA_URL = /^data:[^;]+;base64,/i;
|
|
7
11
|
function currentLevel() {
|
|
8
12
|
const raw = (process.env.OPENROUTER_LOG_LEVEL ?? '').toLowerCase();
|
|
9
13
|
if (raw === 'error' || raw === 'warn' || raw === 'info' || raw === 'debug')
|
|
10
14
|
return raw;
|
|
11
15
|
return 'info';
|
|
12
16
|
}
|
|
17
|
+
function redactString(value, key) {
|
|
18
|
+
if (key && SENSITIVE_KEY.test(key))
|
|
19
|
+
return '[REDACTED]';
|
|
20
|
+
if (DATA_URL.test(value))
|
|
21
|
+
return `[REDACTED data-url ${value.length} chars]`;
|
|
22
|
+
if (value.length > 256 && /^[A-Za-z0-9+/=_-]+$/.test(value)) {
|
|
23
|
+
return `[REDACTED base64 ${value.length} chars]`;
|
|
24
|
+
}
|
|
25
|
+
return value.replace(BEARER, 'Bearer [REDACTED]').replace(SK_OR_KEY, '[REDACTED]');
|
|
26
|
+
}
|
|
27
|
+
function sanitizeCtx(ctx, seen = new WeakSet()) {
|
|
28
|
+
if (seen.has(ctx))
|
|
29
|
+
return { note: 'circular' };
|
|
30
|
+
seen.add(ctx);
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const [key, value] of Object.entries(ctx)) {
|
|
33
|
+
out[key] = sanitizeLogValue(key, value, seen);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function sanitizeLogValue(key, value, seen = new WeakSet()) {
|
|
38
|
+
if (typeof value === 'string')
|
|
39
|
+
return redactString(value, key);
|
|
40
|
+
if (typeof value === 'number' || typeof value === 'boolean' || value === null)
|
|
41
|
+
return value;
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
return value.map((item, index) => sanitizeLogValue(`${key}[${index}]`, item, seen));
|
|
44
|
+
}
|
|
45
|
+
if (value && typeof value === 'object') {
|
|
46
|
+
return sanitizeCtx(value, seen);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
13
50
|
/** Low-level write hook, replaceable in tests. */
|
|
14
51
|
export const _sink = {
|
|
15
52
|
write(line) {
|
|
16
53
|
process.stderr.write(line + '\n');
|
|
17
54
|
},
|
|
18
55
|
};
|
|
19
|
-
|
|
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;
|
|
56
|
+
function emitRecord(record) {
|
|
29
57
|
try {
|
|
30
58
|
_sink.write(JSON.stringify(record));
|
|
31
59
|
}
|
|
32
60
|
catch {
|
|
33
61
|
_sink.write(JSON.stringify({
|
|
34
62
|
ts: new Date().toISOString(),
|
|
35
|
-
level,
|
|
36
|
-
msg,
|
|
63
|
+
level: record.level,
|
|
64
|
+
msg: record.msg,
|
|
37
65
|
ctx: { note: 'unserializable' },
|
|
38
66
|
}));
|
|
39
67
|
}
|
|
40
68
|
}
|
|
69
|
+
export function log(level, msg, ctx) {
|
|
70
|
+
if (LEVEL_ORDER[level] > LEVEL_ORDER[currentLevel()])
|
|
71
|
+
return;
|
|
72
|
+
const record = {
|
|
73
|
+
ts: new Date().toISOString(),
|
|
74
|
+
level,
|
|
75
|
+
msg,
|
|
76
|
+
};
|
|
77
|
+
if (ctx)
|
|
78
|
+
record.ctx = sanitizeCtx(ctx);
|
|
79
|
+
emitRecord(record);
|
|
80
|
+
}
|
|
41
81
|
export const logger = {
|
|
42
82
|
error: (msg, ctx) => log('error', msg, ctx),
|
|
43
83
|
warn: (msg, ctx) => log('warn', msg, ctx),
|
|
@@ -50,17 +90,7 @@ export const logger = {
|
|
|
50
90
|
msg,
|
|
51
91
|
};
|
|
52
92
|
if (ctx)
|
|
53
|
-
record.ctx = ctx;
|
|
54
|
-
|
|
55
|
-
_sink.write(JSON.stringify(record));
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
_sink.write(JSON.stringify({
|
|
59
|
-
ts: new Date().toISOString(),
|
|
60
|
-
level: 'audit',
|
|
61
|
-
msg,
|
|
62
|
-
ctx: { note: 'unserializable' },
|
|
63
|
-
}));
|
|
64
|
-
}
|
|
93
|
+
record.ctx = sanitizeCtx(ctx);
|
|
94
|
+
emitRecord(record);
|
|
65
95
|
},
|
|
66
96
|
};
|
package/dist/model-cache.d.ts
CHANGED
|
@@ -21,6 +21,13 @@ export interface ModelSearchParams {
|
|
|
21
21
|
all?: boolean;
|
|
22
22
|
}
|
|
23
23
|
export declare const MAX_SEARCH_LIMIT = 50;
|
|
24
|
+
/** OpenRouter routing suffixes appended at request time — not part of catalog ids (usually). */
|
|
25
|
+
export declare const ROUTING_SUFFIXES: readonly [":nitro", ":floor", ":free", ":online", ":exacto"];
|
|
26
|
+
export type RoutingSuffix = (typeof ROUTING_SUFFIXES)[number];
|
|
27
|
+
/** Strip a trailing routing suffix from a model slug for catalog lookup. */
|
|
28
|
+
export declare function stripRoutingSuffix(modelId: string): string;
|
|
29
|
+
export declare function clampOffset(offset: number): number;
|
|
30
|
+
export declare function clampLimit(limit: number, fallback?: number): number;
|
|
24
31
|
export declare class ModelCache {
|
|
25
32
|
private static instance;
|
|
26
33
|
private models;
|
|
@@ -38,6 +45,12 @@ export declare class ModelCache {
|
|
|
38
45
|
size(): number;
|
|
39
46
|
get(id: string): OpenRouterModelRecord | null;
|
|
40
47
|
has(id: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a user-supplied model id against the catalog: exact match, strip
|
|
50
|
+
* routing suffixes (`:nitro`, `:free`, …), then case-insensitive id match.
|
|
51
|
+
*/
|
|
52
|
+
lookup(id: string): OpenRouterModelRecord | null;
|
|
53
|
+
catalogHas(id: string): boolean;
|
|
41
54
|
searchPaginated(params: ModelSearchParams, offset: number, limit: number): {
|
|
42
55
|
page: OpenRouterModelRecord[];
|
|
43
56
|
total: number;
|
package/dist/model-cache.js
CHANGED
|
@@ -6,6 +6,29 @@ function getCacheTtlMs() {
|
|
|
6
6
|
return Number.isFinite(n) && n > 0 ? n : 3600000;
|
|
7
7
|
}
|
|
8
8
|
export const MAX_SEARCH_LIMIT = 50;
|
|
9
|
+
/** OpenRouter routing suffixes appended at request time — not part of catalog ids (usually). */
|
|
10
|
+
export const ROUTING_SUFFIXES = [':nitro', ':floor', ':free', ':online', ':exacto'];
|
|
11
|
+
/** Strip a trailing routing suffix from a model slug for catalog lookup. */
|
|
12
|
+
export function stripRoutingSuffix(modelId) {
|
|
13
|
+
for (const suffix of ROUTING_SUFFIXES) {
|
|
14
|
+
if (modelId.endsWith(suffix))
|
|
15
|
+
return modelId.slice(0, -suffix.length);
|
|
16
|
+
}
|
|
17
|
+
return modelId;
|
|
18
|
+
}
|
|
19
|
+
export function clampOffset(offset) {
|
|
20
|
+
if (!Number.isFinite(offset))
|
|
21
|
+
return 0;
|
|
22
|
+
return Math.max(0, Math.floor(offset));
|
|
23
|
+
}
|
|
24
|
+
export function clampLimit(limit, fallback = 10) {
|
|
25
|
+
if (!Number.isFinite(limit))
|
|
26
|
+
return fallback;
|
|
27
|
+
return Math.min(Math.max(1, Math.floor(limit)), MAX_SEARCH_LIMIT);
|
|
28
|
+
}
|
|
29
|
+
function sortedModels(models) {
|
|
30
|
+
return Object.values(models).sort((a, b) => a.id.localeCompare(b.id));
|
|
31
|
+
}
|
|
9
32
|
function buildMatcher(params) {
|
|
10
33
|
const q = params.query?.toLowerCase();
|
|
11
34
|
const providerPrefix = params.provider?.toLowerCase();
|
|
@@ -86,14 +109,47 @@ export class ModelCache {
|
|
|
86
109
|
has(id) {
|
|
87
110
|
return id in this.models;
|
|
88
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Resolve a user-supplied model id against the catalog: exact match, strip
|
|
114
|
+
* routing suffixes (`:nitro`, `:free`, …), then case-insensitive id match.
|
|
115
|
+
*/
|
|
116
|
+
lookup(id) {
|
|
117
|
+
if (!id)
|
|
118
|
+
return null;
|
|
119
|
+
const exact = this.models[id];
|
|
120
|
+
if (exact)
|
|
121
|
+
return exact;
|
|
122
|
+
const stripped = stripRoutingSuffix(id);
|
|
123
|
+
if (stripped !== id) {
|
|
124
|
+
const base = this.models[stripped];
|
|
125
|
+
if (base)
|
|
126
|
+
return base;
|
|
127
|
+
}
|
|
128
|
+
const lower = id.toLowerCase();
|
|
129
|
+
for (const model of Object.values(this.models)) {
|
|
130
|
+
if (model.id.toLowerCase() === lower)
|
|
131
|
+
return model;
|
|
132
|
+
}
|
|
133
|
+
if (stripped !== id) {
|
|
134
|
+
const lowerStripped = stripped.toLowerCase();
|
|
135
|
+
for (const model of Object.values(this.models)) {
|
|
136
|
+
if (model.id.toLowerCase() === lowerStripped)
|
|
137
|
+
return model;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
catalogHas(id) {
|
|
143
|
+
return this.lookup(id) !== null;
|
|
144
|
+
}
|
|
89
145
|
searchPaginated(params, offset, limit) {
|
|
90
146
|
const matches = buildMatcher(params);
|
|
91
|
-
const safeOffset =
|
|
92
|
-
const safeLimit =
|
|
147
|
+
const safeOffset = clampOffset(offset);
|
|
148
|
+
const safeLimit = clampLimit(limit);
|
|
93
149
|
const page = [];
|
|
94
150
|
let total = 0;
|
|
95
151
|
let matchIndex = 0;
|
|
96
|
-
for (const model of
|
|
152
|
+
for (const model of sortedModels(this.models)) {
|
|
97
153
|
if (!matches(model))
|
|
98
154
|
continue;
|
|
99
155
|
if (matchIndex >= safeOffset && page.length < safeLimit) {
|
|
@@ -108,13 +164,13 @@ export class ModelCache {
|
|
|
108
164
|
if (params.all) {
|
|
109
165
|
const matches = buildMatcher(params);
|
|
110
166
|
const results = [];
|
|
111
|
-
for (const model of
|
|
167
|
+
for (const model of sortedModels(this.models)) {
|
|
112
168
|
if (matches(model))
|
|
113
169
|
results.push(model);
|
|
114
170
|
}
|
|
115
171
|
return results;
|
|
116
172
|
}
|
|
117
|
-
const limit =
|
|
173
|
+
const limit = clampLimit(params.limit ?? 10);
|
|
118
174
|
return this.searchPaginated(params, 0, limit).page;
|
|
119
175
|
}
|
|
120
176
|
}
|