@stabgan/openrouter-mcp-multimodal 3.1.0 → 3.2.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 +4 -4
- package/dist/index.js +1 -1
- package/dist/tool-handlers/generate-image.d.ts +31 -1
- package/dist/tool-handlers/generate-image.js +109 -8
- package/dist/tool-handlers.js +19 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
<a href="https://github.com/stabgan/openrouter-mcp-multimodal/network/members"><img src="https://img.shields.io/github/forks/stabgan/openrouter-mcp-multimodal.svg?style=social" alt="GitHub forks" /></a>
|
|
25
25
|
</p>
|
|
26
26
|
<p align="center">
|
|
27
|
-
<sub>
|
|
27
|
+
<sub>4,700+ installs across npm + Docker Hub · ~950 npm installs/month and accelerating</sub>
|
|
28
28
|
</p>
|
|
29
29
|
|
|
30
30
|
<p align="center">
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
---
|
|
41
41
|
[](https://mseep.ai/app/8f27d6d4-0877-4b86-b377-8a33f451e755)
|
|
42
42
|
|
|
43
|
-
Access 300+ LLMs through [OpenRouter](https://openrouter.ai) via the [Model Context Protocol](https://modelcontextprotocol.io). Analyze images, audio, and video. Generate images,
|
|
43
|
+
Access 300+ LLMs — Claude, Gemini, GPT, Llama, Qwen, Grok, and more — through [OpenRouter](https://openrouter.ai) via the [Model Context Protocol](https://modelcontextprotocol.io). Analyze images, audio, and video. Generate images, speech, music, and video (Veo 3.1, Sora 2 Pro, Seedance, Wan). Chat with any model. Works with **Claude Desktop**, **Cursor**, **Kiro**, **VS Code**, **Windsurf**, **Cline**, and any MCP-compatible client. Every tool returns structured `_meta.code` errors so MCP clients can switch on failure modes without parsing strings.
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
|
|
@@ -109,7 +109,7 @@ Install-link audit (2026-04-20, round 4 — HTTPS redirectors only):
|
|
|
109
109
|
| `analyze_image` | Analyze images from local files, URLs, or data URIs. Auto-optimized with sharp. |
|
|
110
110
|
| `analyze_audio` | Analyze/transcribe audio (WAV, MP3, FLAC, OGG, etc.) from files, URLs, or data URIs. |
|
|
111
111
|
| `analyze_video` | Analyze/transcribe video (mp4, mpeg, mov, webm) from files, URLs, or data URIs. |
|
|
112
|
-
| `generate_image` | Generate images from text prompts. Optional path-sandboxed disk save. |
|
|
112
|
+
| `generate_image` | Generate images from text prompts. Supports `aspect_ratio` (14 values), `image_size` (0.5K–4K), and `max_tokens`. Optional path-sandboxed disk save. |
|
|
113
113
|
| `generate_audio` | Generate audio from text. Auto-detects format, wraps raw PCM in WAV. |
|
|
114
114
|
| `generate_video` | Generate video via OpenRouter's async API (Veo 3.1 / Sora 2 Pro / Seedance / Wan). Submits, polls, downloads, saves. |
|
|
115
115
|
| `get_video_status` | Resume polling a `generate_video` job by id. Download + save when complete. |
|
|
@@ -246,7 +246,7 @@ Use generate_audio with prompt "Explain neural networks" and voice "alloy", save
|
|
|
246
246
|
Use generate_audio with model "google/lyria-3-clip-preview" and prompt "upbeat jazz piano trio"
|
|
247
247
|
|
|
248
248
|
# Generate image
|
|
249
|
-
Use generate_image with prompt "a cat astronaut on mars"
|
|
249
|
+
Use generate_image with prompt "a cat astronaut on mars", aspect_ratio "16:9", image_size "1K", save to ./cat.png
|
|
250
250
|
|
|
251
251
|
# Generate video
|
|
252
252
|
Use generate_video with model "google/veo-3.1", prompt "a calm river at sunrise",
|
package/dist/index.js
CHANGED
|
@@ -21,7 +21,7 @@ if (!apiKey) {
|
|
|
21
21
|
process.exit(1);
|
|
22
22
|
}
|
|
23
23
|
const defaultModel = process.env.OPENROUTER_DEFAULT_MODEL || process.env.DEFAULT_MODEL || DEFAULT_MODEL;
|
|
24
|
-
const server = new Server({ name: 'openrouter-multimodal-server', version: '3.
|
|
24
|
+
const server = new Server({ name: 'openrouter-multimodal-server', version: '3.2.0' }, { capabilities: { tools: {} } });
|
|
25
25
|
server.onerror = (error) => console.error('[MCP Error]', error);
|
|
26
26
|
new ToolHandlers(server, apiKey, defaultModel);
|
|
27
27
|
process.on('SIGINT', async () => {
|
|
@@ -6,7 +6,7 @@ export interface GenerateImageToolRequest {
|
|
|
6
6
|
/**
|
|
7
7
|
* Output aspect ratio. Passed through as `image_config.aspect_ratio`.
|
|
8
8
|
* Supported by OpenRouter image models (e.g. `1:1`, `16:9`, `9:16`,
|
|
9
|
-
* `4:3`, `3:4`, `21:9`). Model-dependent
|
|
9
|
+
* `4:3`, `3:4`, `21:9`). Model-dependent. Unsupported values fall back
|
|
10
10
|
* to the model's default. See
|
|
11
11
|
* https://openrouter.ai/docs/guides/overview/multimodal/image-generation
|
|
12
12
|
*/
|
|
@@ -25,6 +25,27 @@ export interface GenerateImageToolRequest {
|
|
|
25
25
|
* for the image payload + any caption.
|
|
26
26
|
*/
|
|
27
27
|
max_tokens?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Optional reference images. Each entry is one of:
|
|
30
|
+
* - a `data:image/...;base64,...` URL,
|
|
31
|
+
* - an `http(s)://` URL (OpenRouter fetches it),
|
|
32
|
+
* - a local file path (sandboxed to `OPENROUTER_INPUT_DIR` /
|
|
33
|
+
* `OPENROUTER_OUTPUT_DIR` / cwd, read + base64-encoded by the server).
|
|
34
|
+
*
|
|
35
|
+
* When provided, the user message becomes multimodal: a text prompt
|
|
36
|
+
* plus one `image_url` block per reference, in array order. Enables
|
|
37
|
+
* character / style consistency and image-to-image refinement on
|
|
38
|
+
* chat-image models that accept image inputs (Gemini Nano Banana,
|
|
39
|
+
* `openai/gpt-5.4-image-2`).
|
|
40
|
+
*/
|
|
41
|
+
input_images?: string[];
|
|
42
|
+
/**
|
|
43
|
+
* Override the default `modalities: ["image","text"]` sent to
|
|
44
|
+
* OpenRouter. Most callers should leave this unset. Provide e.g.
|
|
45
|
+
* `["text"]` to suppress image output for inspection / captioning,
|
|
46
|
+
* or other shapes for future model variants.
|
|
47
|
+
*/
|
|
48
|
+
modalities?: string[];
|
|
28
49
|
}
|
|
29
50
|
export declare function handleGenerateImage(request: {
|
|
30
51
|
params: {
|
|
@@ -66,3 +87,12 @@ export declare function handleGenerateImage(request: {
|
|
|
66
87
|
mime: string;
|
|
67
88
|
};
|
|
68
89
|
}>;
|
|
90
|
+
/**
|
|
91
|
+
* Resolve a caller-supplied input image into a URL the chat-completions
|
|
92
|
+
* API accepts. Local file paths are sandboxed to the workspace root
|
|
93
|
+
* (`OPENROUTER_INPUT_DIR` or `OPENROUTER_OUTPUT_DIR` or cwd) and inlined
|
|
94
|
+
* as base64 data URLs.
|
|
95
|
+
*/
|
|
96
|
+
export declare function resolveInputImage(ref: string): Promise<string>;
|
|
97
|
+
export declare function mimeFromExt(ext: string): string | null;
|
|
98
|
+
export declare function buildUserContent(prompt: string, inputImages?: string[]): Promise<string | OpenAI.Chat.Completions.ChatCompletionContentPart[]>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { resolveSafeOutputPath, UnsafeOutputPathError } from './path-safety.js';
|
|
3
4
|
import { parseBase64DataUrl } from './fetch-utils.js';
|
|
4
5
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
@@ -25,7 +26,7 @@ const VALID_ASPECT_RATIOS = new Set([
|
|
|
25
26
|
]);
|
|
26
27
|
const VALID_IMAGE_SIZES = new Set(['0.5K', '1K', '2K', '4K']);
|
|
27
28
|
export async function handleGenerateImage(request, openai) {
|
|
28
|
-
const { prompt, model, save_path, aspect_ratio, image_size, max_tokens } = request.params.arguments ?? { prompt: '' };
|
|
29
|
+
const { prompt, model, save_path, aspect_ratio, image_size, max_tokens, input_images, modalities, } = request.params.arguments ?? { prompt: '' };
|
|
29
30
|
if (!prompt?.trim()) {
|
|
30
31
|
return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
|
|
31
32
|
}
|
|
@@ -50,9 +51,24 @@ export async function handleGenerateImage(request, openai) {
|
|
|
50
51
|
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
54
|
+
// Build the user message. With no `input_images`, this is the original
|
|
55
|
+
// string content; with refs, it becomes a multimodal
|
|
56
|
+
// ChatCompletionContentPart[] (text preamble + one image_url per ref).
|
|
57
|
+
let content;
|
|
58
|
+
try {
|
|
59
|
+
content = await buildUserContent(prompt, input_images);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err instanceof UnsafeOutputPathError) {
|
|
63
|
+
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
64
|
+
}
|
|
65
|
+
return toolErrorFrom(ErrorCode.INVALID_INPUT, err, 'input_images');
|
|
66
|
+
}
|
|
67
|
+
// Assemble the request body. OpenRouter's image-generation guide
|
|
68
|
+
// requires:
|
|
69
|
+
// - `modalities: ["image", "text"]` so multimodal models (like
|
|
70
|
+
// Gemini) know to emit an image, not just text. Caller can
|
|
71
|
+
// override via the `modalities` field.
|
|
56
72
|
// - `image_config.{aspect_ratio, image_size}` for shape control.
|
|
57
73
|
// The OpenAI SDK doesn't type these fields, but passes unknown members
|
|
58
74
|
// through to the server, so we attach them via a typed cast.
|
|
@@ -63,8 +79,8 @@ export async function handleGenerateImage(request, openai) {
|
|
|
63
79
|
imageConfig.image_size = image_size;
|
|
64
80
|
const body = {
|
|
65
81
|
model: model || DEFAULT_MODEL,
|
|
66
|
-
messages: [{ role: 'user', content
|
|
67
|
-
modalities: ['image', 'text'],
|
|
82
|
+
messages: [{ role: 'user', content }],
|
|
83
|
+
modalities: modalities && modalities.length ? modalities : ['image', 'text'],
|
|
68
84
|
};
|
|
69
85
|
if (Object.keys(imageConfig).length > 0)
|
|
70
86
|
body.image_config = imageConfig;
|
|
@@ -89,8 +105,8 @@ export async function handleGenerateImage(request, openai) {
|
|
|
89
105
|
if (!base64) {
|
|
90
106
|
// Model talked but did not emit an image. Surface this as a distinct
|
|
91
107
|
// condition so callers don't treat chatter as a successful image.
|
|
92
|
-
const
|
|
93
|
-
const text = typeof
|
|
108
|
+
const messageContent = message.content;
|
|
109
|
+
const text = typeof messageContent === 'string' ? messageContent : JSON.stringify(messageContent);
|
|
94
110
|
return toolError(ErrorCode.UPSTREAM_REFUSED, `Model returned no image. Text response: ${text.slice(0, 300)}`, {
|
|
95
111
|
reason: 'no_image_in_response',
|
|
96
112
|
finish_reason: completion.choices[0]?.finish_reason,
|
|
@@ -141,6 +157,91 @@ export async function handleGenerateImage(request, openai) {
|
|
|
141
157
|
},
|
|
142
158
|
};
|
|
143
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Resolve a caller-supplied input image into a URL the chat-completions
|
|
162
|
+
* API accepts. Local file paths are sandboxed to the workspace root
|
|
163
|
+
* (`OPENROUTER_INPUT_DIR` or `OPENROUTER_OUTPUT_DIR` or cwd) and inlined
|
|
164
|
+
* as base64 data URLs.
|
|
165
|
+
*/
|
|
166
|
+
export async function resolveInputImage(ref) {
|
|
167
|
+
const trimmed = ref.trim();
|
|
168
|
+
if (!trimmed)
|
|
169
|
+
throw new Error('empty input_images entry');
|
|
170
|
+
if (trimmed.startsWith('data:'))
|
|
171
|
+
return trimmed;
|
|
172
|
+
if (/^https?:\/\//i.test(trimmed))
|
|
173
|
+
return trimmed;
|
|
174
|
+
const root = path.resolve(process.env.OPENROUTER_INPUT_DIR || process.env.OPENROUTER_OUTPUT_DIR || process.cwd());
|
|
175
|
+
const unsafe = process.env.OPENROUTER_ALLOW_UNSAFE_PATHS === '1' ||
|
|
176
|
+
process.env.OPENROUTER_ALLOW_UNSAFE_PATHS?.toLowerCase() === 'true';
|
|
177
|
+
// Realpath the root first so absolute paths the caller already gave in
|
|
178
|
+
// canonical form (e.g. /private/var/...) and paths we resolve against
|
|
179
|
+
// the root (which may go through /var/... symlinks on macOS) live in
|
|
180
|
+
// the same namespace for the prefix check below.
|
|
181
|
+
const rootReal = await fs.realpath(root).catch(() => root);
|
|
182
|
+
const abs = path.isAbsolute(trimmed)
|
|
183
|
+
? path.resolve(trimmed)
|
|
184
|
+
: path.resolve(rootReal, trimmed);
|
|
185
|
+
if (!unsafe) {
|
|
186
|
+
const withSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
187
|
+
// Prefer realpath for the prefix check so callers can pass paths
|
|
188
|
+
// through symlinks (e.g. macOS `/var/...` → `/private/var/...`)
|
|
189
|
+
// without us rejecting them. If the file doesn't exist yet, fall
|
|
190
|
+
// back to a textual check on the resolved path so traversal
|
|
191
|
+
// (`../escape.png`) is still rejected with the right error type
|
|
192
|
+
// instead of leaking an ENOENT to the caller.
|
|
193
|
+
let canonical;
|
|
194
|
+
try {
|
|
195
|
+
canonical = await fs.realpath(abs);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
canonical = abs;
|
|
199
|
+
}
|
|
200
|
+
if (!(canonical === rootReal || canonical.startsWith(withSep))) {
|
|
201
|
+
throw new UnsafeOutputPathError(`input_images entry resolves outside workspace root (${rootReal}): ${ref}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const buf = await fs.readFile(abs);
|
|
205
|
+
const mime = mimeFromExt(path.extname(abs)) || 'image/png';
|
|
206
|
+
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
207
|
+
}
|
|
208
|
+
export function mimeFromExt(ext) {
|
|
209
|
+
const e = ext.toLowerCase().replace(/^\./, '');
|
|
210
|
+
switch (e) {
|
|
211
|
+
case 'png':
|
|
212
|
+
return 'image/png';
|
|
213
|
+
case 'jpg':
|
|
214
|
+
case 'jpeg':
|
|
215
|
+
return 'image/jpeg';
|
|
216
|
+
case 'webp':
|
|
217
|
+
return 'image/webp';
|
|
218
|
+
case 'gif':
|
|
219
|
+
return 'image/gif';
|
|
220
|
+
default:
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
export async function buildUserContent(prompt, inputImages) {
|
|
225
|
+
if (!inputImages?.length) {
|
|
226
|
+
return `Generate an image: ${prompt}`;
|
|
227
|
+
}
|
|
228
|
+
const parts = [
|
|
229
|
+
{
|
|
230
|
+
type: 'text',
|
|
231
|
+
text: `Generate an image based on this prompt, using the following reference image(s) ` +
|
|
232
|
+
`for visual consistency. Match the appearance, identity, and style of the references ` +
|
|
233
|
+
`closely; do not alter them.\n\nPrompt: ${prompt}`,
|
|
234
|
+
},
|
|
235
|
+
];
|
|
236
|
+
for (const ref of inputImages) {
|
|
237
|
+
const url = await resolveInputImage(ref);
|
|
238
|
+
parts.push({
|
|
239
|
+
type: 'image_url',
|
|
240
|
+
image_url: { url, detail: 'high' },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return parts;
|
|
244
|
+
}
|
|
144
245
|
function extractBase64(message) {
|
|
145
246
|
const images = message.images;
|
|
146
247
|
if (Array.isArray(images) && images.length) {
|
package/dist/tool-handlers.js
CHANGED
|
@@ -185,7 +185,10 @@ export class ToolHandlers {
|
|
|
185
185
|
},
|
|
186
186
|
{
|
|
187
187
|
name: 'generate_image',
|
|
188
|
-
description: 'Generate an image from a text prompt'
|
|
188
|
+
description: 'Generate an image from a text prompt. Optionally conditioned on one or more ' +
|
|
189
|
+
'reference images (file paths, http(s) URLs, or data URLs) for character / style ' +
|
|
190
|
+
'consistency. Sends `modalities: ["image","text"]` by default; override via the ' +
|
|
191
|
+
'`modalities` field if needed.',
|
|
189
192
|
annotations: {
|
|
190
193
|
readOnlyHint: false,
|
|
191
194
|
destructiveHint: false,
|
|
@@ -230,6 +233,21 @@ export class ToolHandlers {
|
|
|
230
233
|
type: 'string',
|
|
231
234
|
description: 'Optional path to save the image. Routed through the OPENROUTER_OUTPUT_DIR sandbox.',
|
|
232
235
|
},
|
|
236
|
+
input_images: {
|
|
237
|
+
type: 'array',
|
|
238
|
+
items: { type: 'string' },
|
|
239
|
+
description: 'Optional reference images for visual consistency. Each entry may be a ' +
|
|
240
|
+
'local file path (sandboxed to OPENROUTER_INPUT_DIR / OPENROUTER_OUTPUT_DIR / ' +
|
|
241
|
+
'cwd), an http(s) URL, or a `data:image/...;base64,...` URL. Inlined as ' +
|
|
242
|
+
'multimodal user content in the order given.',
|
|
243
|
+
},
|
|
244
|
+
modalities: {
|
|
245
|
+
type: 'array',
|
|
246
|
+
items: { type: 'string' },
|
|
247
|
+
description: 'Override the default `modalities: ["image","text"]` sent to OpenRouter. ' +
|
|
248
|
+
'Most callers should leave this unset. Provide e.g. ["text"] to suppress ' +
|
|
249
|
+
'image output for inspection / captioning.',
|
|
250
|
+
},
|
|
233
251
|
},
|
|
234
252
|
required: ['prompt'],
|
|
235
253
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stabgan/openrouter-mcp-multimodal",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"mcpName": "io.github.stabgan/openrouter-multimodal",
|
|
5
5
|
"description": "MCP server for OpenRouter with text chat, image analysis + generation, audio analysis + generation, video analysis, and video generation (Veo 3.1 / Sora 2 Pro / Seedance / Wan)",
|
|
6
6
|
"type": "module",
|