@stabgan/openrouter-mcp-multimodal 4.0.0 → 4.0.1
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 +1 -1
- package/dist/tool-handlers/generate-image.d.ts +3 -3
- package/dist/tool-handlers/generate-image.js +5 -34
- package/dist/tool-handlers/generate-video.js +15 -3
- package/dist/tool-handlers/path-safety.d.ts +11 -0
- package/dist/tool-handlers/path-safety.js +54 -0
- package/dist/tool-handlers/provider-routing.js +6 -2
- package/package.json +1 -1
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: '4.0.
|
|
24
|
+
const server = new Server({ name: 'openrouter-multimodal-server', version: '4.0.1' }, { 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 () => {
|
|
@@ -89,9 +89,9 @@ export declare function handleGenerateImage(request: {
|
|
|
89
89
|
}>;
|
|
90
90
|
/**
|
|
91
91
|
* Resolve a caller-supplied input image into a URL the chat-completions
|
|
92
|
-
* API accepts. Local file paths are sandboxed
|
|
93
|
-
*
|
|
94
|
-
* as base64 data URLs.
|
|
92
|
+
* API accepts. Local file paths are sandboxed via
|
|
93
|
+
* `resolveSafeInputPath` (`OPENROUTER_INPUT_DIR` /
|
|
94
|
+
* `OPENROUTER_OUTPUT_DIR` / cwd) and inlined as base64 data URLs.
|
|
95
95
|
*/
|
|
96
96
|
export declare function resolveInputImage(ref: string): Promise<string>;
|
|
97
97
|
export declare function mimeFromExt(ext: string): string | null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { resolveSafeOutputPath, UnsafeOutputPathError } from './path-safety.js';
|
|
3
|
+
import { resolveSafeOutputPath, resolveSafeInputPath, UnsafeOutputPathError } from './path-safety.js';
|
|
4
4
|
import { parseBase64DataUrl } from './fetch-utils.js';
|
|
5
5
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
6
6
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
@@ -159,9 +159,9 @@ export async function handleGenerateImage(request, openai) {
|
|
|
159
159
|
}
|
|
160
160
|
/**
|
|
161
161
|
* Resolve a caller-supplied input image into a URL the chat-completions
|
|
162
|
-
* API accepts. Local file paths are sandboxed
|
|
163
|
-
*
|
|
164
|
-
* as base64 data URLs.
|
|
162
|
+
* API accepts. Local file paths are sandboxed via
|
|
163
|
+
* `resolveSafeInputPath` (`OPENROUTER_INPUT_DIR` /
|
|
164
|
+
* `OPENROUTER_OUTPUT_DIR` / cwd) and inlined as base64 data URLs.
|
|
165
165
|
*/
|
|
166
166
|
export async function resolveInputImage(ref) {
|
|
167
167
|
const trimmed = ref.trim();
|
|
@@ -171,36 +171,7 @@ export async function resolveInputImage(ref) {
|
|
|
171
171
|
return trimmed;
|
|
172
172
|
if (/^https?:\/\//i.test(trimmed))
|
|
173
173
|
return trimmed;
|
|
174
|
-
const
|
|
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
|
-
}
|
|
174
|
+
const abs = await resolveSafeInputPath(trimmed);
|
|
204
175
|
const buf = await fs.readFile(abs);
|
|
205
176
|
const mime = mimeFromExt(path.extname(abs)) || 'image/png';
|
|
206
177
|
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
@@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs';
|
|
|
2
2
|
import { extname } from 'node:path';
|
|
3
3
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
4
4
|
import { logger } from '../logger.js';
|
|
5
|
-
import { resolveSafeOutputPath, UnsafeOutputPathError, } from './path-safety.js';
|
|
5
|
+
import { resolveSafeOutputPath, resolveSafeInputPath, UnsafeOutputPathError, } from './path-safety.js';
|
|
6
6
|
import { readEnvInt } from './fetch-utils.js';
|
|
7
7
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
8
8
|
const FALLBACK_MODEL = 'google/veo-3.1';
|
|
@@ -54,8 +54,15 @@ async function prepareImageInput(source) {
|
|
|
54
54
|
const mime = (contentType?.split(';')[0]?.trim() || 'image/jpeg').toLowerCase();
|
|
55
55
|
return { mime, data: buffer.toString('base64') };
|
|
56
56
|
}
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
// Local file: sandbox via path-safety's resolveSafeInputPath so
|
|
58
|
+
// generate_video's first_frame_image / last_frame_image /
|
|
59
|
+
// reference_images fields enforce the same OPENROUTER_INPUT_DIR
|
|
60
|
+
// / OPENROUTER_OUTPUT_DIR / cwd scope that generate_image's
|
|
61
|
+
// input_images already uses. Callers can still bypass with
|
|
62
|
+
// OPENROUTER_ALLOW_UNSAFE_PATHS=1 for legacy scripts.
|
|
63
|
+
const abs = await resolveSafeInputPath(source);
|
|
64
|
+
const buf = await fs.readFile(abs);
|
|
65
|
+
const ext = extname(abs).toLowerCase();
|
|
59
66
|
const mime = ext === '.png'
|
|
60
67
|
? 'image/png'
|
|
61
68
|
: ext === '.webp'
|
|
@@ -245,6 +252,11 @@ export async function handleGenerateVideo(request, apiClient, progress) {
|
|
|
245
252
|
await attachFrameImages(args, body);
|
|
246
253
|
}
|
|
247
254
|
catch (err) {
|
|
255
|
+
// Sandbox violation → UNSAFE_PATH; all other decode failures stay
|
|
256
|
+
// as UNSUPPORTED_FORMAT (couldn't read, invalid data URL, etc.).
|
|
257
|
+
if (err instanceof UnsafeOutputPathError) {
|
|
258
|
+
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err, 'Reference/frame image');
|
|
259
|
+
}
|
|
248
260
|
return toolErrorFrom(ErrorCode.UNSUPPORTED_FORMAT, err, 'Reference/frame image');
|
|
249
261
|
}
|
|
250
262
|
let envelope;
|
|
@@ -9,3 +9,14 @@ export declare class UnsafeOutputPathError extends Error {
|
|
|
9
9
|
* (traversal attempt) and the sandbox is enabled.
|
|
10
10
|
*/
|
|
11
11
|
export declare function resolveSafeOutputPath(savePath: string): Promise<string>;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve and validate a caller-supplied INPUT path. Unlike
|
|
14
|
+
* `resolveSafeOutputPath`, this never creates directories — it only
|
|
15
|
+
* confirms the path lives inside the input sandbox and returns the
|
|
16
|
+
* absolute path the caller can `fs.readFile` from.
|
|
17
|
+
*
|
|
18
|
+
* Accepts the same `OPENROUTER_ALLOW_UNSAFE_PATHS=1` legacy bypass.
|
|
19
|
+
* Throws `UnsafeOutputPathError` on traversal attempts (re-used type so
|
|
20
|
+
* handlers map errors uniformly to `ErrorCode.UNSAFE_PATH`).
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveSafeInputPath(inputPath: string): Promise<string>;
|
|
@@ -86,3 +86,57 @@ async function findExistingAncestor(dir) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Root-resolution for caller-supplied INPUT paths. Prefers
|
|
91
|
+
* `OPENROUTER_INPUT_DIR`, then `OPENROUTER_OUTPUT_DIR`, then `process.cwd()`.
|
|
92
|
+
* This mirrors the semantics `generate_image`'s `input_images` originally
|
|
93
|
+
* shipped with; exposing it here lets `generate_video`'s frame and
|
|
94
|
+
* reference images use the same sandbox.
|
|
95
|
+
*/
|
|
96
|
+
function getInputRoot() {
|
|
97
|
+
const inputDir = process.env.OPENROUTER_INPUT_DIR;
|
|
98
|
+
if (inputDir && inputDir.length > 0)
|
|
99
|
+
return path.resolve(inputDir);
|
|
100
|
+
const outputDir = process.env.OPENROUTER_OUTPUT_DIR;
|
|
101
|
+
if (outputDir && outputDir.length > 0)
|
|
102
|
+
return path.resolve(outputDir);
|
|
103
|
+
return process.cwd();
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Resolve and validate a caller-supplied INPUT path. Unlike
|
|
107
|
+
* `resolveSafeOutputPath`, this never creates directories — it only
|
|
108
|
+
* confirms the path lives inside the input sandbox and returns the
|
|
109
|
+
* absolute path the caller can `fs.readFile` from.
|
|
110
|
+
*
|
|
111
|
+
* Accepts the same `OPENROUTER_ALLOW_UNSAFE_PATHS=1` legacy bypass.
|
|
112
|
+
* Throws `UnsafeOutputPathError` on traversal attempts (re-used type so
|
|
113
|
+
* handlers map errors uniformly to `ErrorCode.UNSAFE_PATH`).
|
|
114
|
+
*/
|
|
115
|
+
export async function resolveSafeInputPath(inputPath) {
|
|
116
|
+
if (isUnsafeMode()) {
|
|
117
|
+
return path.resolve(inputPath);
|
|
118
|
+
}
|
|
119
|
+
const root = getInputRoot();
|
|
120
|
+
const rootReal = await fs.realpath(root).catch(() => path.resolve(root));
|
|
121
|
+
const withSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
122
|
+
const abs = path.isAbsolute(inputPath)
|
|
123
|
+
? path.resolve(inputPath)
|
|
124
|
+
: path.resolve(rootReal, inputPath);
|
|
125
|
+
// Prefer realpath for the prefix check so callers can pass paths
|
|
126
|
+
// through symlinks (e.g. macOS `/var/...` → `/private/var/...`)
|
|
127
|
+
// without us rejecting them. If the file doesn't exist yet, fall
|
|
128
|
+
// back to a textual check on the resolved path so traversal
|
|
129
|
+
// (`../escape.png`) is still rejected with the right error type
|
|
130
|
+
// instead of leaking an ENOENT to the caller.
|
|
131
|
+
let canonical;
|
|
132
|
+
try {
|
|
133
|
+
canonical = await fs.realpath(abs);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
canonical = abs;
|
|
137
|
+
}
|
|
138
|
+
if (!(canonical === rootReal || canonical.startsWith(withSep))) {
|
|
139
|
+
throw new UnsafeOutputPathError(`input path resolves outside OPENROUTER_INPUT_DIR (${rootReal}): ${inputPath}`);
|
|
140
|
+
}
|
|
141
|
+
return abs;
|
|
142
|
+
}
|
|
@@ -80,8 +80,12 @@ export function readProviderDefaults() {
|
|
|
80
80
|
if (order)
|
|
81
81
|
out.order = order;
|
|
82
82
|
}
|
|
83
|
-
catch {
|
|
84
|
-
|
|
83
|
+
catch (err) {
|
|
84
|
+
// Don't crash the server on a malformed env var — log once so an
|
|
85
|
+
// operator notices instead of wondering why their ordering is being
|
|
86
|
+
// ignored. All other OPENROUTER_PROVIDER_* fields follow the same
|
|
87
|
+
// "silent drop" policy for consistency.
|
|
88
|
+
console.error(`[openrouter-mcp] OPENROUTER_PROVIDER_ORDER ignored: ${err instanceof Error ? err.message : String(err)}`);
|
|
85
89
|
}
|
|
86
90
|
const requireParams = parseBool(env.OPENROUTER_PROVIDER_REQUIRE_PARAMETERS);
|
|
87
91
|
if (requireParams !== undefined)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stabgan/openrouter-mcp-multimodal",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.1",
|
|
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",
|