@stabgan/openrouter-mcp-multimodal 2.0.0 → 3.1.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.
Files changed (57) hide show
  1. package/README.md +198 -145
  2. package/dist/errors.d.ts +42 -0
  3. package/dist/errors.js +46 -0
  4. package/dist/index.js +1 -1
  5. package/dist/logger.d.ts +22 -0
  6. package/dist/logger.js +47 -0
  7. package/dist/model-cache.d.ts +10 -0
  8. package/dist/model-cache.js +31 -1
  9. package/dist/openrouter-api.d.ts +54 -0
  10. package/dist/openrouter-api.js +136 -12
  11. package/dist/tool-handlers/analyze-audio.d.ts +5 -9
  12. package/dist/tool-handlers/analyze-audio.js +41 -8
  13. package/dist/tool-handlers/analyze-image.d.ts +5 -9
  14. package/dist/tool-handlers/analyze-image.js +38 -8
  15. package/dist/tool-handlers/analyze-video.d.ts +19 -0
  16. package/dist/tool-handlers/analyze-video.js +93 -0
  17. package/dist/tool-handlers/audio-utils.js +7 -9
  18. package/dist/tool-handlers/chat-completion.d.ts +6 -10
  19. package/dist/tool-handlers/chat-completion.js +27 -7
  20. package/dist/tool-handlers/completion-utils.d.ts +27 -0
  21. package/dist/tool-handlers/completion-utils.js +69 -0
  22. package/dist/tool-handlers/fetch-utils.d.ts +32 -0
  23. package/dist/tool-handlers/fetch-utils.js +216 -12
  24. package/dist/tool-handlers/generate-audio.d.ts +32 -12
  25. package/dist/tool-handlers/generate-audio.js +77 -46
  26. package/dist/tool-handlers/generate-image.d.ts +48 -10
  27. package/dist/tool-handlers/generate-image.js +148 -33
  28. package/dist/tool-handlers/generate-video.d.ts +78 -0
  29. package/dist/tool-handlers/generate-video.js +353 -0
  30. package/dist/tool-handlers/get-model-info.d.ts +2 -9
  31. package/dist/tool-handlers/get-model-info.js +15 -5
  32. package/dist/tool-handlers/image-utils.d.ts +17 -1
  33. package/dist/tool-handlers/image-utils.js +66 -13
  34. package/dist/tool-handlers/openrouter-errors.d.ts +18 -0
  35. package/dist/tool-handlers/openrouter-errors.js +99 -0
  36. package/dist/tool-handlers/path-safety.d.ts +11 -0
  37. package/dist/tool-handlers/path-safety.js +88 -0
  38. package/dist/tool-handlers/search-models.d.ts +2 -9
  39. package/dist/tool-handlers/search-models.js +10 -6
  40. package/dist/tool-handlers/validate-model.d.ts +2 -9
  41. package/dist/tool-handlers/validate-model.js +15 -4
  42. package/dist/tool-handlers/video-utils.d.ts +29 -0
  43. package/dist/tool-handlers/video-utils.js +174 -0
  44. package/dist/tool-handlers.js +229 -21
  45. package/package.json +3 -3
  46. package/dist/__tests__/audio-utils.test.d.ts +0 -1
  47. package/dist/__tests__/audio-utils.test.js +0 -120
  48. package/dist/__tests__/fetch-utils.test.d.ts +0 -1
  49. package/dist/__tests__/fetch-utils.test.js +0 -76
  50. package/dist/__tests__/generate-audio.test.d.ts +0 -1
  51. package/dist/__tests__/generate-audio.test.js +0 -90
  52. package/dist/__tests__/image-utils.test.d.ts +0 -1
  53. package/dist/__tests__/image-utils.test.js +0 -75
  54. package/dist/__tests__/integration.test.d.ts +0 -1
  55. package/dist/__tests__/integration.test.js +0 -219
  56. package/dist/__tests__/model-cache.test.d.ts +0 -1
  57. package/dist/__tests__/model-cache.test.js +0 -96
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Output-path sandbox. Tools that write files (`generate_image`,
3
+ * `generate_audio`, future `generate_video`) route their `save_path`
4
+ * through `resolveSafeOutputPath` so an untrusted MCP caller cannot
5
+ * traverse outside the configured output root.
6
+ *
7
+ * Root resolution order:
8
+ * 1. `OPENROUTER_OUTPUT_DIR` env var (if set and non-empty).
9
+ * 2. `process.cwd()`.
10
+ *
11
+ * Set `OPENROUTER_ALLOW_UNSAFE_PATHS=1` to disable the sandbox entirely
12
+ * (legacy v2 behavior). This is discouraged — document the trade-off
13
+ * where it appears in user configs.
14
+ */
15
+ import path from 'node:path';
16
+ import { promises as fs } from 'node:fs';
17
+ function getOutputRoot() {
18
+ const override = process.env.OPENROUTER_OUTPUT_DIR;
19
+ if (override && override.length > 0)
20
+ return path.resolve(override);
21
+ return process.cwd();
22
+ }
23
+ function isUnsafeMode() {
24
+ const v = process.env.OPENROUTER_ALLOW_UNSAFE_PATHS;
25
+ return v === '1' || v?.toLowerCase() === 'true';
26
+ }
27
+ export class UnsafeOutputPathError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.name = 'UnsafeOutputPathError';
31
+ }
32
+ }
33
+ /**
34
+ * Resolve and validate a caller-supplied output path. Creates the parent
35
+ * directory if needed. Returns the absolute path that is safe to write.
36
+ *
37
+ * Throws `UnsafeOutputPathError` when the resolved path escapes the root
38
+ * (traversal attempt) and the sandbox is enabled.
39
+ */
40
+ export async function resolveSafeOutputPath(savePath) {
41
+ if (isUnsafeMode()) {
42
+ const abs = path.resolve(savePath);
43
+ await fs.mkdir(path.dirname(abs), { recursive: true });
44
+ return abs;
45
+ }
46
+ const root = getOutputRoot();
47
+ const rootReal = await fs.realpath(root).catch(() => path.resolve(root));
48
+ // Resolve relative paths against the real root; absolute paths stay as
49
+ // given so we can check them against the root prefix below.
50
+ const candidate = path.isAbsolute(savePath)
51
+ ? path.resolve(savePath)
52
+ : path.resolve(rootReal, savePath);
53
+ // Walk up from the candidate dir to find the first component that exists
54
+ // so we can realpath it. This lets us create new subdirectories under the
55
+ // root while still catching symlink-based traversal.
56
+ const withSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
57
+ const candidateDir = path.dirname(candidate);
58
+ const existingAncestor = await findExistingAncestor(candidateDir);
59
+ const ancestorReal = await fs.realpath(existingAncestor);
60
+ // The realpath of the first-existing ancestor MUST be within the root.
61
+ if (!(ancestorReal === rootReal || ancestorReal.startsWith(withSep))) {
62
+ throw new UnsafeOutputPathError(`save_path resolves outside OPENROUTER_OUTPUT_DIR (${rootReal}). ` +
63
+ `Set OPENROUTER_OUTPUT_DIR to a wider root or OPENROUTER_ALLOW_UNSAFE_PATHS=1 to disable this check.`);
64
+ }
65
+ // Safe to create missing intermediate directories now.
66
+ await fs.mkdir(candidateDir, { recursive: true });
67
+ // Re-realpath the final parent in case mkdir traversed a symlink.
68
+ const parentReal = await fs.realpath(candidateDir);
69
+ if (!(parentReal === rootReal || parentReal.startsWith(withSep))) {
70
+ throw new UnsafeOutputPathError(`save_path escapes OPENROUTER_OUTPUT_DIR via symlink (${rootReal}).`);
71
+ }
72
+ return path.join(parentReal, path.basename(candidate));
73
+ }
74
+ async function findExistingAncestor(dir) {
75
+ let current = dir;
76
+ for (;;) {
77
+ try {
78
+ await fs.access(current);
79
+ return current;
80
+ }
81
+ catch {
82
+ const parent = path.dirname(current);
83
+ if (parent === current)
84
+ return current; // reached root
85
+ current = parent;
86
+ }
87
+ }
88
+ }
@@ -12,16 +12,9 @@ export declare function handleSearchModels(request: {
12
12
  params: {
13
13
  arguments: SearchModelsArgs;
14
14
  };
15
- }, apiClient: OpenRouterAPIClient, modelCache: ModelCache): Promise<{
15
+ }, apiClient: OpenRouterAPIClient, modelCache: ModelCache): Promise<import("../errors.js").ToolErrorResult | {
16
16
  content: {
17
- type: string;
17
+ type: "text";
18
18
  text: string;
19
19
  }[];
20
- isError?: undefined;
21
- } | {
22
- content: {
23
- type: string;
24
- text: string;
25
- }[];
26
- isError: boolean;
27
20
  }>;
@@ -1,13 +1,17 @@
1
+ import { ErrorCode, toolErrorFrom } from '../errors.js';
2
+ import { classifyUpstreamError } from './openrouter-errors.js';
1
3
  export async function handleSearchModels(request, apiClient, modelCache) {
2
4
  try {
3
- if (!modelCache.isValid()) {
4
- modelCache.setModels(await apiClient.getModels());
5
- }
6
- const results = modelCache.search(request.params.arguments);
5
+ await modelCache.ensureFresh(() => apiClient.getModels());
6
+ }
7
+ catch (error) {
8
+ return classifyUpstreamError(error, 'search_models');
9
+ }
10
+ try {
11
+ const results = modelCache.search(request.params.arguments ?? {});
7
12
  return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
8
13
  }
9
14
  catch (error) {
10
- const msg = error instanceof Error ? error.message : String(error);
11
- return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
15
+ return toolErrorFrom(ErrorCode.INTERNAL, error, 'search_models');
12
16
  }
13
17
  }
@@ -6,16 +6,9 @@ export declare function handleValidateModel(request: {
6
6
  model: string;
7
7
  };
8
8
  };
9
- }, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<{
9
+ }, modelCache: ModelCache, apiClient?: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
10
10
  content: {
11
- type: string;
11
+ type: "text";
12
12
  text: string;
13
13
  }[];
14
- isError: boolean;
15
- } | {
16
- content: {
17
- type: string;
18
- text: string;
19
- }[];
20
- isError?: undefined;
21
14
  }>;
@@ -1,15 +1,26 @@
1
+ import { ErrorCode, toolError } from '../errors.js';
2
+ import { classifyUpstreamError } from './openrouter-errors.js';
1
3
  export async function handleValidateModel(request, modelCache, apiClient) {
2
- if (!modelCache.isValid() && apiClient) {
3
- modelCache.setModels(await apiClient.getModels());
4
+ const { model } = request.params.arguments ?? { model: '' };
5
+ if (!model || typeof model !== 'string') {
6
+ return toolError(ErrorCode.INVALID_INPUT, 'model is required.');
7
+ }
8
+ if (apiClient) {
9
+ try {
10
+ await modelCache.ensureFresh(() => apiClient.getModels());
11
+ }
12
+ catch (error) {
13
+ return classifyUpstreamError(error, 'validate_model');
14
+ }
4
15
  }
5
16
  if (!modelCache.isValid()) {
6
- return { content: [{ type: 'text', text: 'No model data available.' }], isError: true };
17
+ return toolError(ErrorCode.INTERNAL, 'No model data available.');
7
18
  }
8
19
  return {
9
20
  content: [
10
21
  {
11
22
  type: 'text',
12
- text: JSON.stringify({ valid: modelCache.has(request.params.arguments.model) }),
23
+ text: JSON.stringify({ valid: modelCache.has(model) }),
13
24
  },
14
25
  ],
15
26
  };
@@ -0,0 +1,29 @@
1
+ export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
2
+ export declare const SUPPORTED_VIDEO_FORMATS: readonly ["mp4", "mpeg", "mov", "webm"];
3
+ export type VideoFormat = (typeof SUPPORTED_VIDEO_FORMATS)[number];
4
+ /** Map a file extension (without the dot) to a canonical VideoFormat. */
5
+ export declare function getVideoFormat(filePath: string): VideoFormat | undefined;
6
+ /** Canonical MIME type for each format. */
7
+ export declare function getVideoMimeType(format: VideoFormat): string;
8
+ /**
9
+ * Detect a container from the first bytes of a buffer. Recognizes mp4/mov
10
+ * (`ftyp` box at offset 4), webm (EBML magic `1A 45 DF A3`), and MPEG-PS
11
+ * (`00 00 01 BA` / `00 00 01 B3`). Returns `undefined` if no match.
12
+ *
13
+ * Intentionally conservative: if the magic doesn't match, the caller falls
14
+ * back to the filename / Content-Type.
15
+ */
16
+ export declare function detectVideoFormat(buffer: Buffer): VideoFormat | undefined;
17
+ export interface VideoData {
18
+ data: string;
19
+ format: VideoFormat;
20
+ mediaType: string;
21
+ sizeBytes: number;
22
+ }
23
+ /**
24
+ * Prepare a video from any source (data URL / HTTP URL / local file) as
25
+ * base64 + MIME. OpenRouter requires the client to send video as either a
26
+ * URL or a data URL; we always base64 it so the tool works regardless of
27
+ * provider quirks.
28
+ */
29
+ export declare function prepareVideoData(source: string): Promise<VideoData>;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Video format detection and fetch utilities. Mirrors the structure of
3
+ * `audio-utils.ts`: all network/security logic comes from `fetch-utils.ts`,
4
+ * this module owns format detection, base64 encoding, and MIME mapping.
5
+ *
6
+ * OpenRouter's video-understanding docs (accessed 2026-04-20) list four
7
+ * supported container formats: mp4, mpeg, mov, webm.
8
+ */
9
+ import path from 'node:path';
10
+ import { promises as fs } from 'node:fs';
11
+ import { readEnvInt, fetchHttpResource, parseBase64DataUrl } from './fetch-utils.js';
12
+ export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
13
+ const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
14
+ const DEFAULT_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024; // 100 MB
15
+ const DEFAULT_MAX_REDIRECTS = 8;
16
+ const DEFAULT_MAX_DATA_URL_BYTES = 100 * 1024 * 1024;
17
+ function getFetchTimeoutMs() {
18
+ return readEnvInt('OPENROUTER_VIDEO_FETCH_TIMEOUT_MS', DEFAULT_FETCH_TIMEOUT_MS, 1000);
19
+ }
20
+ function getMaxDownloadBytes() {
21
+ return readEnvInt('OPENROUTER_VIDEO_MAX_DOWNLOAD_BYTES', DEFAULT_MAX_DOWNLOAD_BYTES, 1024);
22
+ }
23
+ function getMaxRedirects() {
24
+ return readEnvInt('OPENROUTER_VIDEO_MAX_REDIRECTS', DEFAULT_MAX_REDIRECTS, 0);
25
+ }
26
+ function getMaxDataUrlBytes() {
27
+ return readEnvInt('OPENROUTER_VIDEO_MAX_DATA_URL_BYTES', DEFAULT_MAX_DATA_URL_BYTES, 1024);
28
+ }
29
+ export const SUPPORTED_VIDEO_FORMATS = ['mp4', 'mpeg', 'mov', 'webm'];
30
+ /** Map a file extension (without the dot) to a canonical VideoFormat. */
31
+ export function getVideoFormat(filePath) {
32
+ const ext = path.extname(filePath).toLowerCase().slice(1);
33
+ const alias = {
34
+ mp4: 'mp4',
35
+ m4v: 'mp4',
36
+ mpeg: 'mpeg',
37
+ mpg: 'mpeg',
38
+ mov: 'mov',
39
+ qt: 'mov',
40
+ webm: 'webm',
41
+ };
42
+ return alias[ext];
43
+ }
44
+ /** Canonical MIME type for each format. */
45
+ export function getVideoMimeType(format) {
46
+ const map = {
47
+ mp4: 'video/mp4',
48
+ mpeg: 'video/mpeg',
49
+ mov: 'video/mov',
50
+ webm: 'video/webm',
51
+ };
52
+ return map[format];
53
+ }
54
+ function mimeSubtypeToFormat(subtype) {
55
+ const aliasMap = {
56
+ mp4: 'mp4',
57
+ 'x-m4v': 'mp4',
58
+ mpeg: 'mpeg',
59
+ mov: 'mov',
60
+ quicktime: 'mov',
61
+ 'x-quicktime': 'mov',
62
+ webm: 'webm',
63
+ };
64
+ const lower = subtype.toLowerCase();
65
+ return aliasMap[lower];
66
+ }
67
+ function formatFromContentType(ct) {
68
+ if (!ct)
69
+ return undefined;
70
+ const mime = ct.split(';')[0].trim().toLowerCase();
71
+ if (!mime.startsWith('video/'))
72
+ return undefined;
73
+ return mimeSubtypeToFormat(mime.slice(6));
74
+ }
75
+ /**
76
+ * Detect a container from the first bytes of a buffer. Recognizes mp4/mov
77
+ * (`ftyp` box at offset 4), webm (EBML magic `1A 45 DF A3`), and MPEG-PS
78
+ * (`00 00 01 BA` / `00 00 01 B3`). Returns `undefined` if no match.
79
+ *
80
+ * Intentionally conservative: if the magic doesn't match, the caller falls
81
+ * back to the filename / Content-Type.
82
+ */
83
+ export function detectVideoFormat(buffer) {
84
+ if (buffer.length >= 12) {
85
+ const box = buffer.subarray(4, 8).toString('ascii');
86
+ if (box === 'ftyp') {
87
+ // Brand tells mp4 vs mov. 'qt ' and 'mov ' indicate QuickTime.
88
+ const brand = buffer.subarray(8, 12).toString('ascii');
89
+ if (brand === 'qt ' || brand === 'mov ')
90
+ return 'mov';
91
+ return 'mp4';
92
+ }
93
+ }
94
+ if (buffer.length >= 4) {
95
+ // EBML header — WebM & Matroska.
96
+ if (buffer[0] === 0x1a &&
97
+ buffer[1] === 0x45 &&
98
+ buffer[2] === 0xdf &&
99
+ buffer[3] === 0xa3) {
100
+ return 'webm';
101
+ }
102
+ // MPEG-PS / MPEG-TS start codes.
103
+ if (buffer[0] === 0x00 &&
104
+ buffer[1] === 0x00 &&
105
+ buffer[2] === 0x01 &&
106
+ (buffer[3] === 0xba || buffer[3] === 0xb3 || buffer[3] === 0xe0)) {
107
+ return 'mpeg';
108
+ }
109
+ }
110
+ return undefined;
111
+ }
112
+ /**
113
+ * Prepare a video from any source (data URL / HTTP URL / local file) as
114
+ * base64 + MIME. OpenRouter requires the client to send video as either a
115
+ * URL or a data URL; we always base64 it so the tool works regardless of
116
+ * provider quirks.
117
+ */
118
+ export async function prepareVideoData(source) {
119
+ // --- data URL ---
120
+ if (source.startsWith('data:')) {
121
+ const parsed = parseBase64DataUrl(source);
122
+ if (!parsed)
123
+ throw new Error('Invalid video data URL');
124
+ if (!parsed.mediaType.startsWith('video/')) {
125
+ throw new Error(`Data URL is not a video/* MIME: ${parsed.mediaType}`);
126
+ }
127
+ const format = mimeSubtypeToFormat(parsed.mediaType.slice(6));
128
+ if (!format) {
129
+ throw new Error(`Unsupported video format from MIME: ${parsed.mediaType}. Supported: ${SUPPORTED_VIDEO_FORMATS.join(', ')}`);
130
+ }
131
+ const approxBytes = Math.ceil((parsed.base64.length * 3) / 4);
132
+ if (approxBytes > getMaxDataUrlBytes())
133
+ throw new Error('Video data URL too large');
134
+ return {
135
+ data: parsed.base64,
136
+ format,
137
+ mediaType: getVideoMimeType(format),
138
+ sizeBytes: approxBytes,
139
+ };
140
+ }
141
+ // --- HTTP(S) URL ---
142
+ if (source.startsWith('http://') || source.startsWith('https://')) {
143
+ const { buffer, contentType } = await fetchHttpResource(source, {
144
+ timeoutMs: getFetchTimeoutMs(),
145
+ maxBytes: getMaxDownloadBytes(),
146
+ maxRedirects: getMaxRedirects(),
147
+ });
148
+ const urlPath = new URL(source).pathname;
149
+ const format = detectVideoFormat(buffer) ??
150
+ getVideoFormat(urlPath) ??
151
+ formatFromContentType(contentType);
152
+ if (!format) {
153
+ throw new Error(`Could not determine video format from ${source}. Supported: ${SUPPORTED_VIDEO_FORMATS.join(', ')}`);
154
+ }
155
+ return {
156
+ data: buffer.toString('base64'),
157
+ format,
158
+ mediaType: getVideoMimeType(format),
159
+ sizeBytes: buffer.length,
160
+ };
161
+ }
162
+ // --- local file ---
163
+ const buffer = await fs.readFile(source);
164
+ const format = detectVideoFormat(buffer) ?? getVideoFormat(source);
165
+ if (!format) {
166
+ throw new Error(`Unsupported video format for file: ${source}. Supported: ${SUPPORTED_VIDEO_FORMATS.join(', ')}`);
167
+ }
168
+ return {
169
+ data: buffer.toString('base64'),
170
+ format,
171
+ mediaType: getVideoMimeType(format),
172
+ sizeBytes: buffer.length,
173
+ };
174
+ }