@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 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>3,800+ installs across npm + Docker Hub &middot; ~950 npm installs/month and accelerating</sub>
27
+ <sub>4,700+ installs across npm + Docker Hub &middot; ~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
  [![Verified on MseeP](https://mseep.ai/badge.svg)](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, audio, and video. Chat with any model. Every tool returns structured `_meta.code` errors so MCP clients can switch on failure modes without parsing strings.
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" and save to ./cat.png
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.1.0' }, { capabilities: { tools: {} } });
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 unsupported values fall back
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
- // Assemble the request body. OpenRouter's image-generation guide requires
54
- // - `modalities: ["image", "text"]` so multimodal models (like Gemini)
55
- // know to emit an image, not just text;
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: `Generate an image: ${prompt}` }],
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 content = message.content;
93
- const text = typeof content === 'string' ? content : JSON.stringify(content);
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) {
@@ -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.1.0",
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",