@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.
Files changed (64) hide show
  1. package/README.md +108 -37
  2. package/dist/errors.d.ts +5 -20
  3. package/dist/errors.js +1 -10
  4. package/dist/index.js +7 -1
  5. package/dist/logger.js +54 -24
  6. package/dist/model-cache.d.ts +13 -0
  7. package/dist/model-cache.js +61 -5
  8. package/dist/openrouter-api.d.ts +14 -15
  9. package/dist/openrouter-api.js +68 -22
  10. package/dist/openrouter-openai-client.d.ts +9 -0
  11. package/dist/openrouter-openai-client.js +15 -0
  12. package/dist/tool-definitions.d.ts +24 -0
  13. package/dist/tool-definitions.js +283 -170
  14. package/dist/tool-descriptions.js +23 -15
  15. package/dist/tool-handlers/analyze-audio.js +4 -1
  16. package/dist/tool-handlers/analyze-image.js +10 -5
  17. package/dist/tool-handlers/analyze-video.js +9 -5
  18. package/dist/tool-handlers/async-chat.d.ts +17 -0
  19. package/dist/tool-handlers/async-chat.js +112 -31
  20. package/dist/tool-handlers/audio-utils.d.ts +19 -4
  21. package/dist/tool-handlers/audio-utils.js +170 -16
  22. package/dist/tool-handlers/cache.d.ts +3 -3
  23. package/dist/tool-handlers/cache.js +56 -4
  24. package/dist/tool-handlers/chat-completion.js +16 -7
  25. package/dist/tool-handlers/chat-request.d.ts +3 -0
  26. package/dist/tool-handlers/chat-request.js +28 -0
  27. package/dist/tool-handlers/completion-utils.d.ts +5 -11
  28. package/dist/tool-handlers/completion-utils.js +76 -47
  29. package/dist/tool-handlers/fetch-utils.d.ts +14 -0
  30. package/dist/tool-handlers/fetch-utils.js +331 -68
  31. package/dist/tool-handlers/generate-audio.d.ts +6 -51
  32. package/dist/tool-handlers/generate-audio.js +30 -76
  33. package/dist/tool-handlers/generate-image-dedicated.d.ts +2 -12
  34. package/dist/tool-handlers/generate-image-dedicated.js +79 -28
  35. package/dist/tool-handlers/generate-image.d.ts +3 -46
  36. package/dist/tool-handlers/generate-image.js +26 -43
  37. package/dist/tool-handlers/generate-video.d.ts +4 -3
  38. package/dist/tool-handlers/generate-video.js +51 -50
  39. package/dist/tool-handlers/get-model-info.js +1 -1
  40. package/dist/tool-handlers/health-check.js +39 -15
  41. package/dist/tool-handlers/image-utils.js +2 -2
  42. package/dist/tool-handlers/openrouter-errors.d.ts +2 -0
  43. package/dist/tool-handlers/openrouter-errors.js +138 -31
  44. package/dist/tool-handlers/path-safety.d.ts +6 -0
  45. package/dist/tool-handlers/path-safety.js +77 -14
  46. package/dist/tool-handlers/path-utils.d.ts +4 -0
  47. package/dist/tool-handlers/path-utils.js +20 -0
  48. package/dist/tool-handlers/provider-routing.d.ts +2 -0
  49. package/dist/tool-handlers/provider-routing.js +10 -0
  50. package/dist/tool-handlers/rerank.d.ts +1 -4
  51. package/dist/tool-handlers/rerank.js +43 -14
  52. package/dist/tool-handlers/search-models.js +3 -3
  53. package/dist/tool-handlers/speech-to-text.d.ts +1 -0
  54. package/dist/tool-handlers/speech-to-text.js +23 -56
  55. package/dist/tool-handlers/text-to-speech.d.ts +2 -12
  56. package/dist/tool-handlers/text-to-speech.js +29 -22
  57. package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
  58. package/dist/tool-handlers/tool-result-payload.js +98 -0
  59. package/dist/tool-handlers/validate-model.js +1 -1
  60. package/dist/tool-handlers.d.ts +9 -0
  61. package/dist/tool-handlers.js +19 -10
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. 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 **770+** automated tests (unit, mock, regression, and live integration).
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** (Cursor, Claude Desktop, VS Code, etc.) — see [Install](#install) below.
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 | Runtime | Best for | This server |
76
- | :------------------------------------------- | :------------------------------- | :------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------- |
77
- | **[npx](#npx-recommended)** | Node.js 20+ | Most MCP clients (default) | ✅ `@stabgan/openrouter-mcp-multimodal` |
78
- | **[uvx / pipx](#uvx--pipx-python-launcher)** | 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/) |
79
- | **[npm global](#npm-global)** | Node.js 20+ | Pin a version without re-downloading | ✅ |
80
- | **[node (local)](#node-local-clone)** | Node.js 20+ | Contributors / air-gapped builds | ✅ |
81
- | **[Docker Hub](#docker)** | Docker | Isolation, no Node on host | ✅ `stabgan/openrouter-mcp-multimodal` |
82
- | **[GHCR](#ghcr-github-container-registry)** | Docker | GitHub-native OCI pulls | ✅ `ghcr.io/stabgan/openrouter-mcp-multimodal` |
83
- | **[Smithery CLI](#smithery)** | Node.js (via installer) | Interactive install into Claude/Cursor/etc. | ✅ |
84
- | **[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) |
85
- | **[One-click deeplinks](#one-click)** | Node.js | Cursor, VS Code, Kiro | ✅ |
86
- | **[Claude Code CLI](#claude-code-cli)** | Node.js | Terminal-first Claude Code users | ✅ |
87
- | **[MCP Inspector](#mcp-inspector)** | Node.js | Debug / list tools locally | ✅ |
88
- | **Windows `cmd /c npx`** | Node.js | Claude Desktop / Cursor when `npx` not on GUI PATH | ✅ [see below](#windows-npx) |
89
- | pip / uv (direct) | — | Native Python MCP servers only | — use **uvx** row above |
90
- | DXT desktop extensions | — | Bundled Claude Desktop `.dxt` | not yet |
91
- | Remote HTTP / SSE | — | Hosted Smithery / Cloudflare endpoints | via [Smithery](https://smithery.ai/server/@stabgan/openrouter-mcp-multimodal) |
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.6.2"]`
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.6.2 uvx mcp-server-openrouter-multimodal
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.6.2` pins the underlying npm package.
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.6.2
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:latest"
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 | Required | Default | Description |
456
- | :----------------------------- | :------: | :------------------------------------ | :----------------------------------- |
457
- | `OPENROUTER_API_KEY` | **Yes** | — | OpenRouter API key |
458
- | `OPENROUTER_DEFAULT_MODEL` | No | `nvidia/nemotron-nano-12b-v2-vl:free` | Default when tools omit `model` |
459
- | `OPENROUTER_INTEGRATION_MODEL` | No | `google/gemma-4-26b-a4b-it:free` | Model used by live integration tests |
460
- | `OPENROUTER_OUTPUT_DIR` | No | `cwd` | Sandbox root for `save_path` |
461
- | `OPENROUTER_INPUT_DIR` | No | | Sandbox root for local input files |
462
- | `OPENROUTER_LOG_LEVEL` | No | `info` | `error` / `warn` / `info` / `debug` |
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, image/audio/video limits, caching, video polling).
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` | **773** unit + mock tests (no API key, &lt;20s) |
521
+ | `npm test` | **1018** unit + mock tests (no API key, &lt;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. Run `npm run ci` before submitting.
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 interface ToolErrorMeta {
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 interface ToolErrorResult {
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
- process.on('SIGINT', async () => {
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
- 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;
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
- try {
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
  };
@@ -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;
@@ -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 = Math.max(0, offset);
92
- const safeLimit = Math.min(Math.max(1, limit), MAX_SEARCH_LIMIT);
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 Object.values(this.models)) {
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 Object.values(this.models)) {
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 = Math.min(Math.max(1, params.limit ?? 10), MAX_SEARCH_LIMIT);
173
+ const limit = clampLimit(params.limit ?? 10);
118
174
  return this.searchPaginated(params, 0, limit).page;
119
175
  }
120
176
  }