@stabgan/openrouter-mcp-multimodal 3.1.1 → 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/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",
|