@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.
- package/README.md +78 -53
- package/dist/errors.d.ts +3 -12
- package/dist/errors.js +2 -6
- package/dist/index.js +2 -10
- package/dist/logger.d.ts +1 -18
- package/dist/logger.js +0 -7
- package/dist/model-cache.d.ts +1 -20
- package/dist/model-cache.js +1 -20
- package/dist/openrouter-api.d.ts +4 -18
- package/dist/openrouter-api.js +4 -20
- package/dist/openrouter-openai-client.d.ts +9 -0
- package/dist/openrouter-openai-client.js +15 -0
- package/dist/tool-definitions.d.ts +1 -0
- package/dist/tool-definitions.js +719 -0
- package/dist/tool-handlers/analyze-audio.d.ts +0 -5
- package/dist/tool-handlers/analyze-image.d.ts +0 -6
- package/dist/tool-handlers/analyze-image.js +1 -8
- package/dist/tool-handlers/analyze-video.d.ts +0 -5
- package/dist/tool-handlers/analyze-video.js +0 -7
- package/dist/tool-handlers/async-chat.d.ts +17 -13
- package/dist/tool-handlers/async-chat.js +49 -65
- package/dist/tool-handlers/audio-utils.d.ts +1 -4
- package/dist/tool-handlers/audio-utils.js +4 -14
- package/dist/tool-handlers/cache.d.ts +2 -18
- package/dist/tool-handlers/cache.js +1 -19
- package/dist/tool-handlers/chat-completion.d.ts +2 -30
- package/dist/tool-handlers/chat-completion.js +13 -33
- package/dist/tool-handlers/chat-request.d.ts +25 -0
- package/dist/tool-handlers/chat-request.js +38 -0
- package/dist/tool-handlers/completion-utils.d.ts +1 -18
- package/dist/tool-handlers/completion-utils.js +0 -2
- package/dist/tool-handlers/fetch-utils.d.ts +2 -18
- package/dist/tool-handlers/fetch-utils.js +3 -51
- package/dist/tool-handlers/generate-audio.d.ts +3 -37
- package/dist/tool-handlers/generate-audio.js +23 -47
- package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -11
- package/dist/tool-handlers/generate-image-dedicated.js +65 -87
- package/dist/tool-handlers/generate-image-input.d.ts +0 -1
- package/dist/tool-handlers/generate-image-input.js +2 -18
- package/dist/tool-handlers/generate-image.d.ts +2 -45
- package/dist/tool-handlers/generate-image.js +16 -37
- package/dist/tool-handlers/generate-video.d.ts +1 -7
- package/dist/tool-handlers/generate-video.js +34 -142
- package/dist/tool-handlers/health-check.d.ts +1 -9
- package/dist/tool-handlers/health-check.js +1 -12
- package/dist/tool-handlers/image-source.d.ts +14 -0
- package/dist/tool-handlers/image-source.js +23 -0
- package/dist/tool-handlers/image-utils.d.ts +5 -0
- package/dist/tool-handlers/image-utils.js +23 -0
- package/dist/tool-handlers/openai-withresponse.d.ts +1 -10
- package/dist/tool-handlers/openai-withresponse.js +0 -7
- package/dist/tool-handlers/openrouter-errors.d.ts +2 -18
- package/dist/tool-handlers/openrouter-errors.js +2 -34
- package/dist/tool-handlers/path-safety.d.ts +15 -14
- package/dist/tool-handlers/path-safety.js +61 -55
- package/dist/tool-handlers/path-utils.d.ts +2 -0
- package/dist/tool-handlers/path-utils.js +7 -0
- package/dist/tool-handlers/provider-routing.d.ts +0 -9
- package/dist/tool-handlers/provider-routing.js +1 -14
- package/dist/tool-handlers/rerank.js +0 -2
- package/dist/tool-handlers/search-models.d.ts +0 -6
- package/dist/tool-handlers/speech-to-text.js +3 -21
- package/dist/tool-handlers/structured-output.d.ts +1 -4
- package/dist/tool-handlers/structured-output.js +2 -16
- package/dist/tool-handlers/text-to-speech.d.ts +1 -11
- package/dist/tool-handlers/text-to-speech.js +20 -41
- package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
- package/dist/tool-handlers/tool-result-payload.js +96 -0
- package/dist/tool-handlers/video-utils.d.ts +1 -6
- package/dist/tool-handlers/video-utils.js +2 -17
- package/dist/tool-handlers.js +6 -739
- package/dist/tool-icons.d.ts +0 -6
- package/dist/tool-icons.js +1 -8
- package/dist/version.d.ts +1 -15
- package/dist/version.js +1 -15
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="assets/logo.
|
|
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 ·
|
|
9
|
+
One install · 19 tools · 300+ OpenRouter models · text, vision, audio & 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`
|
|
49
|
-
| **Vision** | `analyze_image`, `generate_image`
|
|
50
|
-
| **Audio** | `analyze_audio`, `generate_audio`
|
|
51
|
-
| **Video** | `analyze_video`, `generate_video`, `generate_video_from_image`, `get_video_status` | Clip understanding, Veo
|
|
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 **
|
|
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
|
|
76
|
-
|
|
|
77
|
-
| **[npx](#npx-recommended)**
|
|
78
|
-
| **[uvx / pipx](#uvx--pipx-python-launcher)** | Python 3.10+ **and** Node.js 20+ | Python-first workflows, same pattern as PyPI MCP servers
|
|
79
|
-
| **[npm global](#npm-global)**
|
|
80
|
-
| **[node (local)](#node-local-clone)**
|
|
81
|
-
| **[Docker Hub](#docker)**
|
|
82
|
-
| **[GHCR](#ghcr-github-container-registry)**
|
|
83
|
-
| **[Smithery CLI](#smithery)**
|
|
84
|
-
| **[MCP Registry](#mcp-registry)**
|
|
85
|
-
| **[One-click deeplinks](#one-click)**
|
|
86
|
-
| **[Claude Code CLI](#claude-code-cli)**
|
|
87
|
-
| **[MCP Inspector](#mcp-inspector)**
|
|
88
|
-
| **Windows `cmd /c npx`**
|
|
89
|
-
| pip / uv (direct)
|
|
90
|
-
| DXT desktop extensions
|
|
91
|
-
| Remote HTTP / SSE
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
|
254
|
-
"
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
| Tool
|
|
355
|
-
|
|
|
356
|
-
| `chat_completion`
|
|
357
|
-
| `
|
|
358
|
-
| `
|
|
359
|
-
| `
|
|
360
|
-
| `
|
|
361
|
-
| `
|
|
362
|
-
| `
|
|
363
|
-
| `
|
|
364
|
-
| `
|
|
365
|
-
| `
|
|
366
|
-
| `
|
|
367
|
-
| `
|
|
368
|
-
| `
|
|
369
|
-
| `
|
|
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` | **
|
|
483
|
+
| `npm test` | **773** unit + mock tests (no API key, <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.
|
|
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.
|
|
3
|
-
* `toolError(
|
|
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.
|
|
3
|
-
* `toolError(
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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(),
|
package/dist/model-cache.d.ts
CHANGED
|
@@ -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;
|
package/dist/model-cache.js
CHANGED
|
@@ -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);
|
package/dist/openrouter-api.d.ts
CHANGED
|
@@ -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
|
|
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: {
|
package/dist/openrouter-api.js
CHANGED
|
@@ -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
|
|
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>>;
|