@stabgan/openrouter-mcp-multimodal 4.7.0 → 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 +90 -36
- 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/tool-definitions.d.ts +24 -0
- package/dist/tool-definitions.js +276 -174
- 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 +104 -30
- 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 +4 -15
- package/dist/tool-handlers/generate-audio.js +21 -53
- package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -1
- package/dist/tool-handlers/generate-image-dedicated.js +50 -31
- package/dist/tool-handlers/generate-image.d.ts +1 -1
- package/dist/tool-handlers/generate-image.js +19 -22
- package/dist/tool-handlers/generate-video.d.ts +4 -3
- package/dist/tool-handlers/generate-video.js +42 -18
- 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.js +49 -17
- package/dist/tool-handlers/path-utils.d.ts +2 -0
- package/dist/tool-handlers/path-utils.js +13 -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 +1 -1
- package/dist/tool-handlers/text-to-speech.js +18 -8
- package/dist/tool-handlers/tool-result-payload.js +11 -9
- package/dist/tool-handlers/validate-model.js +1 -1
- package/dist/tool-handlers.d.ts +9 -0
- package/dist/tool-handlers.js +17 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -3
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
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> ·
|
|
35
36
|
<a href="#releasing">Releasing</a> ·
|
|
36
37
|
<a href="#faq">FAQ</a>
|
|
@@ -52,7 +53,7 @@ Unlike text-only MCP servers, one install covers the **full multimodal surface**
|
|
|
52
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 |
|
|
53
54
|
| **Catalog** | `search_models`, `get_model_info`, `validate_model`, `rerank_documents`, `health_check` | Model discovery, validation, reranking, ops health |
|
|
54
55
|
|
|
55
|
-
**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).
|
|
56
57
|
|
|
57
58
|
## Quick start
|
|
58
59
|
|
|
@@ -65,7 +66,16 @@ export OPENROUTER_API_KEY=sk-or-v1-...
|
|
|
65
66
|
npx -y @stabgan/openrouter-mcp-multimodal
|
|
66
67
|
```
|
|
67
68
|
|
|
68
|
-
**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.
|
|
69
79
|
|
|
70
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.
|
|
71
81
|
|
|
@@ -73,23 +83,23 @@ npx -y @stabgan/openrouter-mcp-multimodal
|
|
|
73
83
|
|
|
74
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.
|
|
75
85
|
|
|
76
|
-
| Method
|
|
77
|
-
|
|
|
78
|
-
| **[npx](#
|
|
79
|
-
| **[uvx / pipx](#
|
|
80
|
-
| **[npm global](#
|
|
81
|
-
| **[node (local)](#
|
|
82
|
-
| **[Docker Hub](#
|
|
83
|
-
| **[GHCR](#
|
|
84
|
-
| **[Smithery CLI](#smithery)**
|
|
85
|
-
| **[MCP Registry](#mcp-registry)**
|
|
86
|
-
| **[One-click deeplinks](#one-click)**
|
|
87
|
-
| **[Claude Code CLI](#claude-code-cli)**
|
|
88
|
-
| **[MCP Inspector](#mcp-inspector)**
|
|
89
|
-
| **Windows `cmd /c npx`**
|
|
90
|
-
| pip / uv (direct)
|
|
91
|
-
| DXT desktop extensions
|
|
92
|
-
| 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) |
|
|
93
103
|
|
|
94
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.
|
|
95
105
|
|
|
@@ -130,7 +140,7 @@ npx -y @stabgan/openrouter-mcp-multimodal
|
|
|
130
140
|
}
|
|
131
141
|
```
|
|
132
142
|
|
|
133
|
-
Pin a release: `"args": ["-y", "@stabgan/openrouter-mcp-multimodal@4.
|
|
143
|
+
Pin a release: `"args": ["-y", "@stabgan/openrouter-mcp-multimodal@4.8.0"]`
|
|
134
144
|
|
|
135
145
|
</details>
|
|
136
146
|
|
|
@@ -142,7 +152,7 @@ Install [uv](https://docs.astral.sh/uv/getting-started/installation/) (includes
|
|
|
142
152
|
```bash
|
|
143
153
|
export OPENROUTER_API_KEY=sk-or-v1-...
|
|
144
154
|
uvx mcp-server-openrouter-multimodal
|
|
145
|
-
# pin npm version: OPENROUTER_MCP_NPM_VERSION=4.
|
|
155
|
+
# pin npm version: OPENROUTER_MCP_NPM_VERSION=4.8.0 uvx mcp-server-openrouter-multimodal
|
|
146
156
|
```
|
|
147
157
|
|
|
148
158
|
```json
|
|
@@ -161,7 +171,7 @@ uvx mcp-server-openrouter-multimodal
|
|
|
161
171
|
|
|
162
172
|
**pipx equivalent:** `pipx run mcp-server-openrouter-multimodal`
|
|
163
173
|
|
|
164
|
-
Optional: `OPENROUTER_MCP_NPM_VERSION=4.
|
|
174
|
+
Optional: `OPENROUTER_MCP_NPM_VERSION=4.8.0` pins the underlying npm package.
|
|
165
175
|
|
|
166
176
|
</details>
|
|
167
177
|
|
|
@@ -242,7 +252,7 @@ Use `-i` (interactive stdio). Avoid `-t` (TTY corrupts MCP framing on some hosts
|
|
|
242
252
|
|
|
243
253
|
```bash
|
|
244
254
|
docker run --rm -i -e OPENROUTER_API_KEY=sk-or-v1-... \
|
|
245
|
-
ghcr.io/stabgan/openrouter-mcp-multimodal:4.
|
|
255
|
+
ghcr.io/stabgan/openrouter-mcp-multimodal:4.8.0
|
|
246
256
|
```
|
|
247
257
|
|
|
248
258
|
```json
|
|
@@ -256,7 +266,7 @@ docker run --rm -i -e OPENROUTER_API_KEY=sk-or-v1-... \
|
|
|
256
266
|
"-i",
|
|
257
267
|
"-e",
|
|
258
268
|
"OPENROUTER_API_KEY=sk-or-v1-...",
|
|
259
|
-
"ghcr.io/stabgan/openrouter-mcp-multimodal:
|
|
269
|
+
"ghcr.io/stabgan/openrouter-mcp-multimodal:4.8.0"
|
|
260
270
|
]
|
|
261
271
|
}
|
|
262
272
|
}
|
|
@@ -379,6 +389,26 @@ If still failing, use the full path from `where npx` as the command.
|
|
|
379
389
|
|
|
380
390
|
Errors use a closed `_meta.code` taxonomy: `INVALID_INPUT` · `UNSAFE_PATH` · `UPSTREAM_*` · `MODEL_NOT_FOUND` · `JOB_STILL_RUNNING` · and more.
|
|
381
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
|
+
|
|
382
412
|
## Examples
|
|
383
413
|
|
|
384
414
|
### Chat (free model)
|
|
@@ -437,32 +467,40 @@ Errors use a closed `_meta.code` taxonomy: `INVALID_INPUT` · `UNSAFE_PATH` · `
|
|
|
437
467
|
|
|
438
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.**
|
|
439
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
|
+
|
|
440
472
|
More examples: [docs/plans/tool-description-improvement.md](./docs/plans/tool-description-improvement.md)
|
|
441
473
|
|
|
442
474
|
## Security
|
|
443
475
|
|
|
444
|
-
- **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`)
|
|
445
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+)
|
|
446
479
|
- **SSRF protection** — private/reserved IPs blocked on URL fetches
|
|
447
480
|
- **Untrusted content** — analyze outputs tagged `_meta.content_is_untrusted: true`
|
|
448
481
|
|
|
449
482
|
Override sandboxes only with `OPENROUTER_ALLOW_UNSAFE_PATHS=1` (discouraged).
|
|
450
483
|
|
|
484
|
+
Report vulnerabilities: **[SECURITY.md](./SECURITY.md)** (private disclosure — do not file public issues for exploits).
|
|
485
|
+
|
|
451
486
|
## Configuration
|
|
452
487
|
|
|
453
488
|
<details>
|
|
454
489
|
<summary><strong>Environment variables</strong></summary>
|
|
455
490
|
|
|
456
|
-
| Variable
|
|
457
|
-
|
|
|
458
|
-
| `OPENROUTER_API_KEY`
|
|
459
|
-
| `OPENROUTER_DEFAULT_MODEL`
|
|
460
|
-
| `
|
|
461
|
-
| `
|
|
462
|
-
| `
|
|
463
|
-
| `
|
|
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` |
|
|
464
502
|
|
|
465
|
-
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).
|
|
466
504
|
|
|
467
505
|
</details>
|
|
468
506
|
|
|
@@ -480,7 +518,7 @@ npm run build
|
|
|
480
518
|
|
|
481
519
|
| Command | What it runs |
|
|
482
520
|
| :------------------------- | :--------------------------------------------------------- |
|
|
483
|
-
| `npm test` | **
|
|
521
|
+
| `npm test` | **1018** unit + mock tests (no API key, <20s) |
|
|
484
522
|
| `npm run test:regression` | Security + schema regression guards |
|
|
485
523
|
| `npm run test:integration` | **16** live OpenRouter scenarios (**requires** `.env` key) |
|
|
486
524
|
| `npm run test:e2e` | Full MCP stdio smoke (`scripts/live-e2e.mjs`) |
|
|
@@ -509,6 +547,22 @@ Full checklist, file list, CI secrets, and agent instructions:
|
|
|
509
547
|
- **[`docs/RELEASING.md`](docs/RELEASING.md)** — maintainer release guide
|
|
510
548
|
- **[`AGENTS.md`](AGENTS.md)** — quick reference for AI agents
|
|
511
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
|
+
|
|
512
566
|
## FAQ
|
|
513
567
|
|
|
514
568
|
### Do I need paid OpenRouter credits?
|
|
@@ -525,7 +579,7 @@ This server adds MCP tool schemas, security sandboxes, error taxonomy, model cac
|
|
|
525
579
|
|
|
526
580
|
### Where is the security advisory for path traversal?
|
|
527
581
|
|
|
528
|
-
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/`.
|
|
529
583
|
|
|
530
584
|
## Compatibility
|
|
531
585
|
|
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
|
}
|