@stabgan/openrouter-mcp-multimodal 4.6.1 → 4.7.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 (76) hide show
  1. package/README.md +78 -53
  2. package/dist/errors.d.ts +3 -12
  3. package/dist/errors.js +2 -6
  4. package/dist/index.js +2 -10
  5. package/dist/logger.d.ts +1 -18
  6. package/dist/logger.js +0 -7
  7. package/dist/model-cache.d.ts +1 -20
  8. package/dist/model-cache.js +1 -20
  9. package/dist/openrouter-api.d.ts +4 -18
  10. package/dist/openrouter-api.js +4 -20
  11. package/dist/openrouter-openai-client.d.ts +9 -0
  12. package/dist/openrouter-openai-client.js +15 -0
  13. package/dist/tool-definitions.d.ts +1 -0
  14. package/dist/tool-definitions.js +719 -0
  15. package/dist/tool-handlers/analyze-audio.d.ts +0 -5
  16. package/dist/tool-handlers/analyze-image.d.ts +0 -6
  17. package/dist/tool-handlers/analyze-image.js +1 -8
  18. package/dist/tool-handlers/analyze-video.d.ts +0 -5
  19. package/dist/tool-handlers/analyze-video.js +0 -7
  20. package/dist/tool-handlers/async-chat.d.ts +17 -13
  21. package/dist/tool-handlers/async-chat.js +49 -65
  22. package/dist/tool-handlers/audio-utils.d.ts +1 -4
  23. package/dist/tool-handlers/audio-utils.js +4 -14
  24. package/dist/tool-handlers/cache.d.ts +2 -18
  25. package/dist/tool-handlers/cache.js +1 -19
  26. package/dist/tool-handlers/chat-completion.d.ts +2 -30
  27. package/dist/tool-handlers/chat-completion.js +13 -33
  28. package/dist/tool-handlers/chat-request.d.ts +25 -0
  29. package/dist/tool-handlers/chat-request.js +38 -0
  30. package/dist/tool-handlers/completion-utils.d.ts +1 -18
  31. package/dist/tool-handlers/completion-utils.js +0 -2
  32. package/dist/tool-handlers/fetch-utils.d.ts +2 -18
  33. package/dist/tool-handlers/fetch-utils.js +3 -51
  34. package/dist/tool-handlers/generate-audio.d.ts +3 -37
  35. package/dist/tool-handlers/generate-audio.js +23 -47
  36. package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -11
  37. package/dist/tool-handlers/generate-image-dedicated.js +65 -87
  38. package/dist/tool-handlers/generate-image-input.d.ts +0 -1
  39. package/dist/tool-handlers/generate-image-input.js +2 -18
  40. package/dist/tool-handlers/generate-image.d.ts +2 -45
  41. package/dist/tool-handlers/generate-image.js +16 -37
  42. package/dist/tool-handlers/generate-video.d.ts +1 -7
  43. package/dist/tool-handlers/generate-video.js +34 -142
  44. package/dist/tool-handlers/health-check.d.ts +1 -9
  45. package/dist/tool-handlers/health-check.js +1 -12
  46. package/dist/tool-handlers/image-source.d.ts +14 -0
  47. package/dist/tool-handlers/image-source.js +23 -0
  48. package/dist/tool-handlers/image-utils.d.ts +5 -0
  49. package/dist/tool-handlers/image-utils.js +23 -0
  50. package/dist/tool-handlers/openai-withresponse.d.ts +1 -10
  51. package/dist/tool-handlers/openai-withresponse.js +0 -7
  52. package/dist/tool-handlers/openrouter-errors.d.ts +2 -18
  53. package/dist/tool-handlers/openrouter-errors.js +2 -34
  54. package/dist/tool-handlers/path-safety.d.ts +15 -14
  55. package/dist/tool-handlers/path-safety.js +61 -55
  56. package/dist/tool-handlers/path-utils.d.ts +2 -0
  57. package/dist/tool-handlers/path-utils.js +7 -0
  58. package/dist/tool-handlers/provider-routing.d.ts +0 -9
  59. package/dist/tool-handlers/provider-routing.js +1 -14
  60. package/dist/tool-handlers/rerank.js +0 -2
  61. package/dist/tool-handlers/search-models.d.ts +0 -6
  62. package/dist/tool-handlers/speech-to-text.js +3 -21
  63. package/dist/tool-handlers/structured-output.d.ts +1 -4
  64. package/dist/tool-handlers/structured-output.js +2 -16
  65. package/dist/tool-handlers/text-to-speech.d.ts +1 -11
  66. package/dist/tool-handlers/text-to-speech.js +20 -41
  67. package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
  68. package/dist/tool-handlers/tool-result-payload.js +96 -0
  69. package/dist/tool-handlers/video-utils.d.ts +1 -6
  70. package/dist/tool-handlers/video-utils.js +2 -17
  71. package/dist/tool-handlers.js +6 -739
  72. package/dist/tool-icons.d.ts +0 -6
  73. package/dist/tool-icons.js +1 -8
  74. package/dist/version.d.ts +1 -15
  75. package/dist/version.js +1 -15
  76. package/package.json +3 -2
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  <p align="center">
2
- <img src="assets/logo.svg" alt="OpenRouter MCP Multimodal — MCP server for chat, vision, audio, and video AI tools" width="128" height="128" />
2
+ <img src="assets/logo.png" alt="OpenRouter MCP Multimodal — MCP server for chat, vision, audio, and video AI tools" width="128" height="128" />
3
3
  </p>
4
4
 
5
5
  <h1 align="center">OpenRouter MCP Multimodal</h1>
6
6
 
7
7
  <p align="center">
8
8
  <strong>The MCP server for multimodal AI agents.</strong><br/>
9
- One install · 14 tools · 300+ OpenRouter models · text, vision, audio &amp; video — analysis and generation.
9
+ One install · 19 tools · 300+ OpenRouter models · text, vision, audio &amp; video — analysis and generation.
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -32,6 +32,7 @@
32
32
  <a href="#examples">Examples</a> ·
33
33
  <a href="#security">Security</a> ·
34
34
  <a href="#development">Development</a> ·
35
+ <a href="#releasing">Releasing</a> ·
35
36
  <a href="#faq">FAQ</a>
36
37
  </p>
37
38
 
@@ -43,15 +44,15 @@
43
44
 
44
45
  Unlike text-only MCP servers, one install covers the **full multimodal surface**:
45
46
 
46
- | Capability | Tools | Highlights |
47
- | :---------- | :-------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------- |
48
- | **Chat** | `chat_completion` | 300+ models, `:nitro` / `:exacto` suffixes, provider routing, web search, response caching, reasoning tokens |
49
- | **Vision** | `analyze_image`, `generate_image` | OCR, captioning, VQA, image generation with reference inputs |
50
- | **Audio** | `analyze_audio`, `generate_audio` | Transcription, speech/music generation |
51
- | **Video** | `analyze_video`, `generate_video`, `generate_video_from_image`, `get_video_status` | Clip understanding, Veo / Sora / Seedance / Wan generation with progress notifications |
52
- | **Catalog** | `search_models`, `get_model_info`, `validate_model`, `rerank_documents`, `health_check` | Model discovery, validation, reranking, ops health |
47
+ | Capability | Tools | Highlights |
48
+ | :---------- | :-------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49
+ | **Chat** | `chat_completion`, `start_chat_completion`, `get_chat_completion_status` | 300+ models, `:nitro` / `:floor` / `:free` / `:online` / `:exacto` suffixes, provider routing, web search, response caching, reasoning tokens, async jobs for long-running models |
50
+ | **Vision** | `analyze_image`, `generate_image`, `generate_image_dedicated` | OCR, captioning, VQA, image generation with reference inputs, dedicated Image API with resolution/quality/format control |
51
+ | **Audio** | `analyze_audio`, `generate_audio`, `text_to_speech`, `speech_to_text` | Transcription, speech/music generation, dedicated TTS (OpenAI/Gemini/Voxtral voices), dedicated STT (Whisper/GPT-4o Transcribe) |
52
+ | **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
+ | **Catalog** | `search_models`, `get_model_info`, `validate_model`, `rerank_documents`, `health_check` | Model discovery, validation, reranking, ops health |
53
54
 
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, async video progress notifications, and **650+** automated tests (unit, mock, regression, and live integration).
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 **770+** automated tests (unit, mock, regression, and live integration).
55
56
 
56
57
  ## Quick start
57
58
 
@@ -72,23 +73,23 @@ npx -y @stabgan/openrouter-mcp-multimodal
72
73
 
73
74
  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
75
 
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) |
76
+ | Method | Runtime | Best for | This server |
77
+ | :------------------------------------------- | :------------------------------- | :------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------- |
78
+ | **[npx](#npx-recommended)** | Node.js 20+ | Most MCP clients (default) | ✅ `@stabgan/openrouter-mcp-multimodal` |
79
+ | **[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/) |
80
+ | **[npm global](#npm-global)** | Node.js 20+ | Pin a version without re-downloading | ✅ |
81
+ | **[node (local)](#node-local-clone)** | Node.js 20+ | Contributors / air-gapped builds | ✅ |
82
+ | **[Docker Hub](#docker)** | Docker | Isolation, no Node on host | ✅ `stabgan/openrouter-mcp-multimodal` |
83
+ | **[GHCR](#ghcr-github-container-registry)** | Docker | GitHub-native OCI pulls | ✅ `ghcr.io/stabgan/openrouter-mcp-multimodal` |
84
+ | **[Smithery CLI](#smithery)** | Node.js (via installer) | Interactive install into Claude/Cursor/etc. | ✅ |
85
+ | **[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) |
86
+ | **[One-click deeplinks](#one-click)** | Node.js | Cursor, VS Code, Kiro | ✅ |
87
+ | **[Claude Code CLI](#claude-code-cli)** | Node.js | Terminal-first Claude Code users | ✅ |
88
+ | **[MCP Inspector](#mcp-inspector)** | Node.js | Debug / list tools locally | ✅ |
89
+ | **Windows `cmd /c npx`** | Node.js | Claude Desktop / Cursor when `npx` not on GUI PATH | ✅ [see below](#windows-npx) |
90
+ | pip / uv (direct) | — | Native Python MCP servers only | — use **uvx** row above |
91
+ | DXT desktop extensions | — | Bundled Claude Desktop `.dxt` | not yet |
92
+ | Remote HTTP / SSE | — | Hosted Smithery / Cloudflare endpoints | via [Smithery](https://smithery.ai/server/@stabgan/openrouter-mcp-multimodal) |
92
93
 
93
94
  > **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
95
 
@@ -129,7 +130,7 @@ npx -y @stabgan/openrouter-mcp-multimodal
129
130
  }
130
131
  ```
131
132
 
132
- Pin a release: `"args": ["-y", "@stabgan/openrouter-mcp-multimodal@4.5.3"]`
133
+ Pin a release: `"args": ["-y", "@stabgan/openrouter-mcp-multimodal@4.7.0"]`
133
134
 
134
135
  </details>
135
136
 
@@ -141,7 +142,7 @@ Install [uv](https://docs.astral.sh/uv/getting-started/installation/) (includes
141
142
  ```bash
142
143
  export OPENROUTER_API_KEY=sk-or-v1-...
143
144
  uvx mcp-server-openrouter-multimodal
144
- # pin npm version: OPENROUTER_MCP_NPM_VERSION=4.5.3 uvx mcp-server-openrouter-multimodal
145
+ # pin npm version: OPENROUTER_MCP_NPM_VERSION=4.7.0 uvx mcp-server-openrouter-multimodal
145
146
  ```
146
147
 
147
148
  ```json
@@ -160,7 +161,7 @@ uvx mcp-server-openrouter-multimodal
160
161
 
161
162
  **pipx equivalent:** `pipx run mcp-server-openrouter-multimodal`
162
163
 
163
- Optional: `OPENROUTER_MCP_NPM_VERSION=4.5.3` pins the underlying npm package.
164
+ Optional: `OPENROUTER_MCP_NPM_VERSION=4.7.0` pins the underlying npm package.
164
165
 
165
166
  </details>
166
167
 
@@ -241,7 +242,7 @@ Use `-i` (interactive stdio). Avoid `-t` (TTY corrupts MCP framing on some hosts
241
242
 
242
243
  ```bash
243
244
  docker run --rm -i -e OPENROUTER_API_KEY=sk-or-v1-... \
244
- ghcr.io/stabgan/openrouter-mcp-multimodal:4.5.3
245
+ ghcr.io/stabgan/openrouter-mcp-multimodal:4.7.0
245
246
  ```
246
247
 
247
248
  ```json
@@ -250,8 +251,11 @@ docker run --rm -i -e OPENROUTER_API_KEY=sk-or-v1-... \
250
251
  "openrouter": {
251
252
  "command": "docker",
252
253
  "args": [
253
- "run", "--rm", "-i",
254
- "-e", "OPENROUTER_API_KEY=sk-or-v1-...",
254
+ "run",
255
+ "--rm",
256
+ "-i",
257
+ "-e",
258
+ "OPENROUTER_API_KEY=sk-or-v1-...",
255
259
  "ghcr.io/stabgan/openrouter-mcp-multimodal:latest"
256
260
  ]
257
261
  }
@@ -349,24 +353,29 @@ If still failing, use the full path from `where npx` as the command.
349
353
 
350
354
  ## Tools
351
355
 
352
- 14 MCP tools. Each description includes **Use when**, **Good/Bad examples**, **Fails when**, and **Works with** so agents pick the right tool and recover from errors.
353
-
354
- | Tool | Purpose |
355
- | :-------------------------- | :---------------------------------------------------------- |
356
- | `chat_completion` | Text chat, web search, provider routing, caching, reasoning |
357
- | `analyze_image` | Vision local path, URL, or data URL + `question` |
358
- | `analyze_audio` | Transcribe / analyze audio files |
359
- | `analyze_video` | Describe / Q&A over video files |
360
- | `generate_image` | Text-to-image with optional reference images |
361
- | `generate_audio` | Text-to-speech / music |
362
- | `generate_video` | Text-to-video (async, resumable) |
363
- | `generate_video_from_image` | Image-to-video (narrower schema) |
364
- | `get_video_status` | Poll / resume video jobs |
365
- | `search_models` | Paginated model catalog search |
366
- | `get_model_info` | Pricing, context, modalities |
367
- | `validate_model` | Cheap model ID existence check |
368
- | `rerank_documents` | Relevance ranking for RAG |
369
- | `health_check` | API key + reachability probe |
356
+ 19 MCP tools. Each description includes **Use when**, **Good/Bad examples**, **Fails when**, and **Works with** so agents pick the right tool and recover from errors.
357
+
358
+ | Tool | Purpose |
359
+ | :--------------------------- | :------------------------------------------------------------------------- |
360
+ | `chat_completion` | Text chat, web search, provider routing, caching, reasoning |
361
+ | `start_chat_completion` | Async background job for long-running reasoning models |
362
+ | `get_chat_completion_status` | Poll / retrieve async completion results |
363
+ | `analyze_image` | Vision local path, URL, or data URL + `question` |
364
+ | `analyze_audio` | Transcribe / analyze audio files |
365
+ | `analyze_video` | Describe / Q&A over video files |
366
+ | `generate_image` | Text-to-image via chat completions with reference images |
367
+ | `generate_image_dedicated` | Text-to-image via dedicated `/api/v1/images` (resolution, quality, format) |
368
+ | `generate_audio` | Text-to-speech / music via chat completions |
369
+ | `text_to_speech` | Dedicated TTS (`/api/v1/audio/speech`) — voices, speed, format |
370
+ | `speech_to_text` | Dedicated STT (`/api/v1/audio/transcriptions`) — Whisper, GPT-4o |
371
+ | `generate_video` | Text-to-video (async, resumable) |
372
+ | `generate_video_from_image` | Image-to-video (narrower schema) |
373
+ | `get_video_status` | Poll / resume video jobs |
374
+ | `search_models` | Paginated model catalog search |
375
+ | `get_model_info` | Pricing, context, modalities |
376
+ | `validate_model` | Cheap model ID existence check |
377
+ | `rerank_documents` | Relevance ranking for RAG |
378
+ | `health_check` | API key + reachability probe |
370
379
 
371
380
  Errors use a closed `_meta.code` taxonomy: `INVALID_INPUT` · `UNSAFE_PATH` · `UPSTREAM_*` · `MODEL_NOT_FOUND` · `JOB_STILL_RUNNING` · and more.
372
381
 
@@ -471,7 +480,7 @@ npm run build
471
480
 
472
481
  | Command | What it runs |
473
482
  | :------------------------- | :--------------------------------------------------------- |
474
- | `npm test` | **652** unit + mock tests (no API key, &lt;2s) |
483
+ | `npm test` | **773** unit + mock tests (no API key, &lt;20s) |
475
484
  | `npm run test:regression` | Security + schema regression guards |
476
485
  | `npm run test:integration` | **16** live OpenRouter scenarios (**requires** `.env` key) |
477
486
  | `npm run test:e2e` | Full MCP stdio smoke (`scripts/live-e2e.mjs`) |
@@ -484,8 +493,22 @@ Mock tests live under `src/__tests__/mock/` and cover handlers, path sandboxes,
484
493
  ```bash
485
494
  npm run lint
486
495
  npm run format:check
496
+ npm run version:check # package.json vs src/version.ts, server.json, pyproject.toml
487
497
  ```
488
498
 
499
+ ## Releasing
500
+
501
+ 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.
502
+
503
+ **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.
504
+
505
+ **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`.
506
+
507
+ Full checklist, file list, CI secrets, and agent instructions:
508
+
509
+ - **[`docs/RELEASING.md`](docs/RELEASING.md)** — maintainer release guide
510
+ - **[`AGENTS.md`](AGENTS.md)** — quick reference for AI agents
511
+
489
512
  ## FAQ
490
513
 
491
514
  ### Do I need paid OpenRouter credits?
@@ -514,4 +537,6 @@ Apache 2.0 — see [LICENSE](./LICENSE).
514
537
 
515
538
  ## Contributing
516
539
 
517
- Issues and PRs welcome. For large changes, open an issue first. Run `npm run ci` before submitting.
540
+ Issues and PRs welcome. For large changes, open an issue first.
541
+
542
+ 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,10 +1,6 @@
1
1
  /**
2
- * Closed error-code taxonomy for MCP tool responses. Every handler uses
3
- * `toolError(code, message, details?, opts?)` instead of ad-hoc text so
4
- * clients can switch on `_meta.code` without regex-parsing free text.
5
- *
6
- * Adding a new code requires a design.md note — this set is intentionally
7
- * small and stable.
2
+ * Closed error-code taxonomy for MCP tool responses.
3
+ * Every handler uses `toolError()` so clients can switch on `_meta.code`.
8
4
  */
9
5
  export declare const ErrorCode: {
10
6
  readonly INVALID_INPUT: "INVALID_INPUT";
@@ -24,12 +20,7 @@ export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
24
20
  export interface ToolErrorMeta {
25
21
  code: ErrorCode;
26
22
  details?: Record<string, unknown>;
27
- /**
28
- * Optional human-readable next steps the agent can take. Inspired by
29
- * the Apigene "structured errors" best practice: rather than a raw
30
- * string for the agent to interpret, list concrete options like
31
- * "Wait and retry" or "Lower resolution to 480p".
32
- */
23
+ /** Optional next steps for the agent (e.g. "Wait and retry"). */
33
24
  suggestions?: string[];
34
25
  /**
35
26
  * For rate-limit / backoff errors, the number of seconds the caller
package/dist/errors.js CHANGED
@@ -1,10 +1,6 @@
1
1
  /**
2
- * Closed error-code taxonomy for MCP tool responses. Every handler uses
3
- * `toolError(code, message, details?, opts?)` instead of ad-hoc text so
4
- * clients can switch on `_meta.code` without regex-parsing free text.
5
- *
6
- * Adding a new code requires a design.md note — this set is intentionally
7
- * small and stable.
2
+ * Closed error-code taxonomy for MCP tool responses.
3
+ * Every handler uses `toolError()` so clients can switch on `_meta.code`.
8
4
  */
9
5
  export const ErrorCode = {
10
6
  INVALID_INPUT: 'INVALID_INPUT',
package/dist/index.js CHANGED
@@ -9,18 +9,13 @@ import { logger } from './logger.js';
9
9
  import { SERVER_VERSION } from './version.js';
10
10
  import { SERVER_ICON } from './tool-icons.js';
11
11
  const DEFAULT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
12
- // Exit on fatal errors to prevent silent zombie processes (issue #5).
13
- // We log an explicit whitelist of fields rather than the raw error object
14
- // to avoid ever echoing sensitive SDK internals (request bodies, auth
15
- // headers) in a future version. Defense-in-depth against a changed
16
- // APIError.toString() in openai-node.
12
+ // Log whitelisted fields only avoid leaking auth headers from SDK errors.
17
13
  function logFatal(kind, err) {
18
14
  const e = err;
19
15
  logger.error('fatal', {
20
16
  kind,
21
17
  name: e?.name ?? 'unknown',
22
18
  msg: e?.message ?? String(err),
23
- // Stack traces are developer-only — trim to avoid unbounded log lines.
24
19
  stack: e?.stack?.split('\n').slice(0, 10).join('\n'),
25
20
  });
26
21
  }
@@ -52,10 +47,7 @@ process.on('SIGINT', async () => {
52
47
  await server.close();
53
48
  process.exit(0);
54
49
  });
55
- // Ensure stdin emits raw Buffers some hosts (e.g. Claude Desktop) may set
56
- // encoding on the stdin pipe, which causes the MCP SDK's ReadBuffer to receive
57
- // strings instead of Buffers. ReadBuffer.readMessage() calls subarray() which
58
- // doesn't exist on strings, triggering an infinite error loop.
50
+ // Stdin may arrive as strings on some MCP hosts; re-wrap as raw Buffers for the SDK.
59
51
  const stdinStream = process.stdin;
60
52
  stdinStream.setEncoding?.(undefined);
61
53
  const safeStdin = new Readable({
package/dist/logger.d.ts CHANGED
@@ -1,16 +1,5 @@
1
1
  /**
2
- * Stderr-bound JSON line logger. stdout is the MCP transport so logs MUST
3
- * go to stderr or the client will choke. Output is one JSON object per line:
4
- *
5
- * {"ts":"2026-04-20T14:03:10.123Z","level":"info","msg":"job_submitted",
6
- * "ctx":{"model":"google/veo-3.1","id":"vid_abc"}}
7
- *
8
- * Level is filtered by OPENROUTER_LOG_LEVEL (error|warn|info|debug,
9
- * default info). Unknown values fall through to info.
10
- *
11
- * `audit` is a special level that ALWAYS writes (bypasses the level filter),
12
- * intended for cost-incurring / destructive operations so operators can
13
- * trace them after the fact.
2
+ * Stderr-bound JSON line logger. stdout is reserved for MCP transport.
14
3
  */
15
4
  export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
16
5
  /** Low-level write hook, replaceable in tests. */
@@ -23,11 +12,5 @@ export declare const logger: {
23
12
  warn: (msg: string, ctx?: Record<string, unknown>) => void;
24
13
  info: (msg: string, ctx?: Record<string, unknown>) => void;
25
14
  debug: (msg: string, ctx?: Record<string, unknown>) => void;
26
- /**
27
- * Always-on audit line. Bypasses OPENROUTER_LOG_LEVEL. Use for paid or
28
- * destructive operations (generate_video, generate_audio, generate_image)
29
- * so operators can trace unintended spend via `docker logs` or a log
30
- * aggregator.
31
- */
32
15
  audit(msg: string, ctx?: Record<string, unknown>): void;
33
16
  };
package/dist/logger.js CHANGED
@@ -30,7 +30,6 @@ export function log(level, msg, ctx) {
30
30
  _sink.write(JSON.stringify(record));
31
31
  }
32
32
  catch {
33
- // Fall back to a short-form record if `ctx` contains something unserializable.
34
33
  _sink.write(JSON.stringify({
35
34
  ts: new Date().toISOString(),
36
35
  level,
@@ -44,12 +43,6 @@ export const logger = {
44
43
  warn: (msg, ctx) => log('warn', msg, ctx),
45
44
  info: (msg, ctx) => log('info', msg, ctx),
46
45
  debug: (msg, ctx) => log('debug', msg, ctx),
47
- /**
48
- * Always-on audit line. Bypasses OPENROUTER_LOG_LEVEL. Use for paid or
49
- * destructive operations (generate_video, generate_audio, generate_image)
50
- * so operators can trace unintended spend via `docker logs` or a log
51
- * aggregator.
52
- */
53
46
  audit(msg, ctx) {
54
47
  const record = {
55
48
  ts: new Date().toISOString(),
@@ -25,38 +25,19 @@ export declare class ModelCache {
25
25
  private static instance;
26
26
  private models;
27
27
  private fetchedAt;
28
- /**
29
- * Separate from `fetchedAt`: set whenever we successfully CALL the
30
- * fetcher (even if the response happens to be empty). Used by
31
- * `isValid()` so a successful-but-empty fetch still counts as "fresh"
32
- * and we don't hot-loop re-fetching the upstream.
33
- */
34
28
  private populatedAt;
35
29
  private inflight;
36
30
  static getInstance(): ModelCache;
37
31
  isValid(): boolean;
38
32
  setModels(models: OpenRouterModelRecord[]): void;
39
- /**
40
- * Force the cache back into an uninitialized state. Used by tests that
41
- * need to assert `ensureFresh()` actually calls the fetcher. Also useful
42
- * for ops (`health_check --reset`) if we ever expose such a knob.
43
- */
33
+ /** Reset cache state (tests). */
44
34
  reset(): void;
45
- /**
46
- * Populate the cache using `fetcher` if stale, coalescing concurrent callers
47
- * so only one request hits the upstream API per stale window. Callers that
48
- * arrive while a populate is in flight await the same promise.
49
- */
50
35
  ensureFresh(fetcher: () => Promise<OpenRouterModelRecord[]>): Promise<void>;
51
36
  getAll(): OpenRouterModelRecord[];
52
37
  /** Number of models currently cached. Used by health_check. */
53
38
  size(): number;
54
39
  get(id: string): OpenRouterModelRecord | null;
55
40
  has(id: string): boolean;
56
- /**
57
- * Single-pass paginated search: O(n) time, O(limit) extra space for the page.
58
- * Avoids materializing the full filtered array when only one page is needed.
59
- */
60
41
  searchPaginated(params: ModelSearchParams, offset: number, limit: number): {
61
42
  page: OpenRouterModelRecord[];
62
43
  total: number;
@@ -36,12 +36,6 @@ export class ModelCache {
36
36
  static instance;
37
37
  models = {};
38
38
  fetchedAt = 0;
39
- /**
40
- * Separate from `fetchedAt`: set whenever we successfully CALL the
41
- * fetcher (even if the response happens to be empty). Used by
42
- * `isValid()` so a successful-but-empty fetch still counts as "fresh"
43
- * and we don't hot-loop re-fetching the upstream.
44
- */
45
39
  populatedAt = 0;
46
40
  inflight = null;
47
41
  static getInstance() {
@@ -56,22 +50,13 @@ export class ModelCache {
56
50
  this.fetchedAt = Date.now();
57
51
  this.populatedAt = this.fetchedAt;
58
52
  }
59
- /**
60
- * Force the cache back into an uninitialized state. Used by tests that
61
- * need to assert `ensureFresh()` actually calls the fetcher. Also useful
62
- * for ops (`health_check --reset`) if we ever expose such a knob.
63
- */
53
+ /** Reset cache state (tests). */
64
54
  reset() {
65
55
  this.models = {};
66
56
  this.fetchedAt = 0;
67
57
  this.populatedAt = 0;
68
58
  this.inflight = null;
69
59
  }
70
- /**
71
- * Populate the cache using `fetcher` if stale, coalescing concurrent callers
72
- * so only one request hits the upstream API per stale window. Callers that
73
- * arrive while a populate is in flight await the same promise.
74
- */
75
60
  async ensureFresh(fetcher) {
76
61
  if (this.isValid())
77
62
  return;
@@ -101,10 +86,6 @@ export class ModelCache {
101
86
  has(id) {
102
87
  return id in this.models;
103
88
  }
104
- /**
105
- * Single-pass paginated search: O(n) time, O(limit) extra space for the page.
106
- * Avoids materializing the full filtered array when only one page is needed.
107
- */
108
89
  searchPaginated(params, offset, limit) {
109
90
  const matches = buildMatcher(params);
110
91
  const safeOffset = Math.max(0, offset);
@@ -21,33 +21,19 @@ export declare class OpenRouterAPIClient {
21
21
  submitVideoJob(body: Record<string, unknown>): Promise<VideoJobEnvelope>;
22
22
  /** Poll a submitted video-generation job by id. */
23
23
  pollVideoJob(id: string): Promise<VideoJobStatus>;
24
- /** Download the generated video binary. Returns `{ buffer, contentType }`.
25
- * This intentionally does NOT go through our SSRF-guarded `fetchHttpResource`
26
- * because the URL is always OpenRouter itself (trusted origin) — and it can
27
- * return arbitrarily large bodies that the caller bounds via
28
- * `OPENROUTER_VIDEO_MAX_DOWNLOAD_BYTES`.
29
- */
24
+ /** Download generated video binary (trusted OpenRouter origin). */
30
25
  downloadVideoContent(id: string, index?: number, maxBytes?: number): Promise<{
31
26
  buffer: Buffer;
32
27
  contentType: string | null;
33
28
  }>;
34
- /**
35
- * POST /images — dedicated image generation endpoint.
36
- * Returns structured response with base64 image data.
37
- */
29
+ /** POST /images — dedicated image generation. */
38
30
  generateImage(body: Record<string, unknown>, headers?: Record<string, string>): Promise<ImageGenerationResponse>;
39
- /**
40
- * POST /audio/speech — dedicated text-to-speech endpoint.
41
- * Returns raw audio bytes.
42
- */
31
+ /** POST /audio/speech — text-to-speech. */
43
32
  generateSpeech(body: Record<string, unknown>, headers?: Record<string, string>): Promise<{
44
33
  buffer: Buffer;
45
34
  contentType: string;
46
35
  }>;
47
- /**
48
- * POST /audio/transcriptions — dedicated speech-to-text endpoint.
49
- * Accepts base64-encoded audio and returns transcription text.
50
- */
36
+ /** POST /audio/transcriptions — speech-to-text. */
51
37
  transcribeAudio(body: Record<string, unknown>, headers?: Record<string, string>): Promise<TranscriptionResponse>;
52
38
  /** POST /rerank — re-order documents by relevance to a query. */
53
39
  rerank(params: {
@@ -39,8 +39,6 @@ async function fetchWithRetry(url, init, { retries = 2, timeoutMs = DEFAULT_TIME
39
39
  if (res.status === 429 || res.status >= 500) {
40
40
  if (attempt < retries) {
41
41
  const retryAfter = parseRetryAfter(res.headers.get('retry-after'));
42
- // Release the connection before retrying so undici/pool doesn't
43
- // keep it open while we sleep.
44
42
  try {
45
43
  await res.body?.cancel();
46
44
  }
@@ -104,12 +102,7 @@ export class OpenRouterAPIClient {
104
102
  }
105
103
  return (await res.json());
106
104
  }
107
- /** Download the generated video binary. Returns `{ buffer, contentType }`.
108
- * This intentionally does NOT go through our SSRF-guarded `fetchHttpResource`
109
- * because the URL is always OpenRouter itself (trusted origin) — and it can
110
- * return arbitrarily large bodies that the caller bounds via
111
- * `OPENROUTER_VIDEO_MAX_DOWNLOAD_BYTES`.
112
- */
105
+ /** Download generated video binary (trusted OpenRouter origin). */
113
106
  async downloadVideoContent(id, index = 0, maxBytes = 256 * 1024 * 1024) {
114
107
  const url = `${BASE_URL}/videos/${encodeURIComponent(id)}/content?index=${index}`;
115
108
  const res = await fetchWithRetry(url, { headers: this.authHeaders() }, { retries: 1, timeoutMs: VIDEO_TIMEOUT_MS * 2 });
@@ -151,10 +144,7 @@ export class OpenRouterAPIClient {
151
144
  }
152
145
  return { buffer: Buffer.concat(chunks), contentType: res.headers.get('content-type') };
153
146
  }
154
- /**
155
- * POST /images — dedicated image generation endpoint.
156
- * Returns structured response with base64 image data.
157
- */
147
+ /** POST /images — dedicated image generation. */
158
148
  async generateImage(body, headers) {
159
149
  const res = await fetchWithRetry(`${BASE_URL}/images`, {
160
150
  method: 'POST',
@@ -167,10 +157,7 @@ export class OpenRouterAPIClient {
167
157
  }
168
158
  return (await res.json());
169
159
  }
170
- /**
171
- * POST /audio/speech — dedicated text-to-speech endpoint.
172
- * Returns raw audio bytes.
173
- */
160
+ /** POST /audio/speech — text-to-speech. */
174
161
  async generateSpeech(body, headers) {
175
162
  const res = await fetchWithRetry(`${BASE_URL}/audio/speech`, {
176
163
  method: 'POST',
@@ -185,10 +172,7 @@ export class OpenRouterAPIClient {
185
172
  const buf = Buffer.from(await res.arrayBuffer());
186
173
  return { buffer: buf, contentType };
187
174
  }
188
- /**
189
- * POST /audio/transcriptions — dedicated speech-to-text endpoint.
190
- * Accepts base64-encoded audio and returns transcription text.
191
- */
175
+ /** POST /audio/transcriptions — speech-to-text. */
192
176
  async transcribeAudio(body, headers) {
193
177
  const res = await fetchWithRetry(`${BASE_URL}/audio/transcriptions`, {
194
178
  method: 'POST',
@@ -0,0 +1,9 @@
1
+ import OpenAI from 'openai';
2
+ declare const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
3
+ declare const OPENROUTER_ATTRIBUTION_HEADERS: {
4
+ readonly 'HTTP-Referer': "https://github.com/stabgan/openrouter-mcp-multimodal";
5
+ readonly 'X-Title': "openrouter-mcp-multimodal";
6
+ };
7
+ /** OpenAI SDK client configured for OpenRouter chat/completions endpoints. */
8
+ export declare function createOpenRouterOpenAIClient(apiKey: string): OpenAI;
9
+ export { OPENROUTER_BASE_URL, OPENROUTER_ATTRIBUTION_HEADERS };
@@ -0,0 +1,15 @@
1
+ import OpenAI from 'openai';
2
+ const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
3
+ const OPENROUTER_ATTRIBUTION_HEADERS = {
4
+ 'HTTP-Referer': 'https://github.com/stabgan/openrouter-mcp-multimodal',
5
+ 'X-Title': 'openrouter-mcp-multimodal',
6
+ };
7
+ /** OpenAI SDK client configured for OpenRouter chat/completions endpoints. */
8
+ export function createOpenRouterOpenAIClient(apiKey) {
9
+ return new OpenAI({
10
+ apiKey,
11
+ baseURL: OPENROUTER_BASE_URL,
12
+ defaultHeaders: { ...OPENROUTER_ATTRIBUTION_HEADERS },
13
+ });
14
+ }
15
+ export { OPENROUTER_BASE_URL, OPENROUTER_ATTRIBUTION_HEADERS };
@@ -0,0 +1 @@
1
+ export declare const TOOL_DEFINITIONS: Array<Record<string, unknown>>;