@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 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.0' }, { capabilities: { tools: {} } });
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 to the workspace root
93
- * (`OPENROUTER_INPUT_DIR` or `OPENROUTER_OUTPUT_DIR` or cwd) and inlined
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 to the workspace root
163
- * (`OPENROUTER_INPUT_DIR` or `OPENROUTER_OUTPUT_DIR` or cwd) and inlined
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 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
- }
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
- const buf = await fs.readFile(source);
58
- const ext = extname(source).toLowerCase();
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
- /* silently drop malformed env var */
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.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",