@stabgan/openrouter-mcp-multimodal 2.0.0 → 3.0.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 +137 -43
- package/dist/errors.d.ts +42 -0
- package/dist/errors.js +46 -0
- package/dist/index.js +1 -1
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +47 -0
- package/dist/model-cache.d.ts +10 -0
- package/dist/model-cache.js +31 -1
- package/dist/openrouter-api.d.ts +54 -0
- package/dist/openrouter-api.js +128 -12
- package/dist/tool-handlers/analyze-audio.d.ts +5 -9
- package/dist/tool-handlers/analyze-audio.js +41 -8
- package/dist/tool-handlers/analyze-image.d.ts +5 -9
- package/dist/tool-handlers/analyze-image.js +38 -8
- package/dist/tool-handlers/analyze-video.d.ts +19 -0
- package/dist/tool-handlers/analyze-video.js +93 -0
- package/dist/tool-handlers/audio-utils.js +7 -9
- package/dist/tool-handlers/chat-completion.d.ts +6 -10
- package/dist/tool-handlers/chat-completion.js +27 -7
- package/dist/tool-handlers/completion-utils.d.ts +27 -0
- package/dist/tool-handlers/completion-utils.js +69 -0
- package/dist/tool-handlers/fetch-utils.d.ts +21 -0
- package/dist/tool-handlers/fetch-utils.js +166 -11
- package/dist/tool-handlers/generate-audio.d.ts +32 -12
- package/dist/tool-handlers/generate-audio.js +77 -46
- package/dist/tool-handlers/generate-image.d.ts +26 -10
- package/dist/tool-handlers/generate-image.js +79 -27
- package/dist/tool-handlers/generate-video.d.ts +78 -0
- package/dist/tool-handlers/generate-video.js +353 -0
- package/dist/tool-handlers/get-model-info.js +8 -2
- package/dist/tool-handlers/image-utils.d.ts +17 -1
- package/dist/tool-handlers/image-utils.js +66 -13
- package/dist/tool-handlers/openrouter-errors.d.ts +18 -0
- package/dist/tool-handlers/openrouter-errors.js +99 -0
- package/dist/tool-handlers/path-safety.d.ts +11 -0
- package/dist/tool-handlers/path-safety.js +88 -0
- package/dist/tool-handlers/search-models.js +1 -3
- package/dist/tool-handlers/validate-model.js +8 -2
- package/dist/tool-handlers/video-utils.d.ts +29 -0
- package/dist/tool-handlers/video-utils.js +174 -0
- package/dist/tool-handlers.js +199 -21
- package/package.json +3 -3
- package/dist/__tests__/audio-utils.test.d.ts +0 -1
- package/dist/__tests__/audio-utils.test.js +0 -120
- package/dist/__tests__/fetch-utils.test.d.ts +0 -1
- package/dist/__tests__/fetch-utils.test.js +0 -76
- package/dist/__tests__/generate-audio.test.d.ts +0 -1
- package/dist/__tests__/generate-audio.test.js +0 -90
- package/dist/__tests__/image-utils.test.d.ts +0 -1
- package/dist/__tests__/image-utils.test.js +0 -75
- package/dist/__tests__/integration.test.d.ts +0 -1
- package/dist/__tests__/integration.test.js +0 -219
- package/dist/__tests__/model-cache.test.d.ts +0 -1
- package/dist/__tests__/model-cache.test.js +0 -96
package/dist/tool-handlers.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
1
|
+
import { CallToolRequestSchema, ErrorCode as McpErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import OpenAI from 'openai';
|
|
3
3
|
import { ModelCache } from './model-cache.js';
|
|
4
4
|
import { OpenRouterAPIClient } from './openrouter-api.js';
|
|
@@ -10,6 +10,8 @@ import { handleValidateModel } from './tool-handlers/validate-model.js';
|
|
|
10
10
|
import { handleGenerateImage } from './tool-handlers/generate-image.js';
|
|
11
11
|
import { handleAnalyzeAudio } from './tool-handlers/analyze-audio.js';
|
|
12
12
|
import { handleGenerateAudio } from './tool-handlers/generate-audio.js';
|
|
13
|
+
import { handleAnalyzeVideo } from './tool-handlers/analyze-video.js';
|
|
14
|
+
import { handleGenerateVideo, handleGetVideoStatus, } from './tool-handlers/generate-video.js';
|
|
13
15
|
function wrapToolArgs(a) {
|
|
14
16
|
return { params: { arguments: a ?? {} } };
|
|
15
17
|
}
|
|
@@ -33,6 +35,11 @@ export class ToolHandlers {
|
|
|
33
35
|
{
|
|
34
36
|
name: 'chat_completion',
|
|
35
37
|
description: 'Send messages to an OpenRouter model and get a response',
|
|
38
|
+
annotations: {
|
|
39
|
+
readOnlyHint: false,
|
|
40
|
+
destructiveHint: false,
|
|
41
|
+
idempotentHint: false,
|
|
42
|
+
},
|
|
36
43
|
inputSchema: {
|
|
37
44
|
type: 'object',
|
|
38
45
|
properties: {
|
|
@@ -60,6 +67,11 @@ export class ToolHandlers {
|
|
|
60
67
|
{
|
|
61
68
|
name: 'analyze_image',
|
|
62
69
|
description: 'Analyze an image using a vision model',
|
|
70
|
+
annotations: {
|
|
71
|
+
readOnlyHint: true,
|
|
72
|
+
destructiveHint: false,
|
|
73
|
+
idempotentHint: false,
|
|
74
|
+
},
|
|
63
75
|
inputSchema: {
|
|
64
76
|
type: 'object',
|
|
65
77
|
properties: {
|
|
@@ -70,15 +82,75 @@ export class ToolHandlers {
|
|
|
70
82
|
required: ['image_path'],
|
|
71
83
|
},
|
|
72
84
|
},
|
|
85
|
+
{
|
|
86
|
+
name: 'analyze_audio',
|
|
87
|
+
description: 'Analyze or transcribe an audio file using a multimodal model',
|
|
88
|
+
annotations: {
|
|
89
|
+
readOnlyHint: true,
|
|
90
|
+
destructiveHint: false,
|
|
91
|
+
idempotentHint: false,
|
|
92
|
+
},
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
audio_path: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
description: 'File path, URL, or data URL (base64-encoded audio)',
|
|
99
|
+
},
|
|
100
|
+
question: {
|
|
101
|
+
type: 'string',
|
|
102
|
+
description: 'Question or instruction about the audio (default: transcribe)',
|
|
103
|
+
},
|
|
104
|
+
model: { type: 'string' },
|
|
105
|
+
},
|
|
106
|
+
required: ['audio_path'],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'analyze_video',
|
|
111
|
+
description: 'Analyze or transcribe a video file using a multimodal model. Accepts mp4, mpeg, mov, or webm from a local file path, HTTP(S) URL, or base64 data URL. Default model: google/gemini-2.5-flash.',
|
|
112
|
+
annotations: {
|
|
113
|
+
readOnlyHint: true,
|
|
114
|
+
destructiveHint: false,
|
|
115
|
+
idempotentHint: false,
|
|
116
|
+
},
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
video_path: {
|
|
121
|
+
type: 'string',
|
|
122
|
+
description: 'File path, HTTP(S) URL, or base64 data URL. Supported formats: mp4, mpeg, mov, webm.',
|
|
123
|
+
},
|
|
124
|
+
question: {
|
|
125
|
+
type: 'string',
|
|
126
|
+
description: 'Question or instruction about the video (default: describe).',
|
|
127
|
+
},
|
|
128
|
+
model: { type: 'string', description: 'Override the model ID.' },
|
|
129
|
+
},
|
|
130
|
+
required: ['video_path'],
|
|
131
|
+
},
|
|
132
|
+
},
|
|
73
133
|
{
|
|
74
134
|
name: 'search_models',
|
|
75
135
|
description: 'Search available OpenRouter models',
|
|
136
|
+
annotations: {
|
|
137
|
+
readOnlyHint: true,
|
|
138
|
+
destructiveHint: false,
|
|
139
|
+
idempotentHint: true,
|
|
140
|
+
},
|
|
76
141
|
inputSchema: {
|
|
77
142
|
type: 'object',
|
|
78
143
|
properties: {
|
|
79
144
|
query: { type: 'string' },
|
|
80
145
|
provider: { type: 'string' },
|
|
81
|
-
capabilities: {
|
|
146
|
+
capabilities: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: {
|
|
149
|
+
vision: { type: 'boolean' },
|
|
150
|
+
audio: { type: 'boolean' },
|
|
151
|
+
video: { type: 'boolean' },
|
|
152
|
+
},
|
|
153
|
+
},
|
|
82
154
|
limit: { type: 'number', minimum: 1, maximum: 50 },
|
|
83
155
|
},
|
|
84
156
|
},
|
|
@@ -86,6 +158,11 @@ export class ToolHandlers {
|
|
|
86
158
|
{
|
|
87
159
|
name: 'get_model_info',
|
|
88
160
|
description: 'Get details about a specific model',
|
|
161
|
+
annotations: {
|
|
162
|
+
readOnlyHint: true,
|
|
163
|
+
destructiveHint: false,
|
|
164
|
+
idempotentHint: true,
|
|
165
|
+
},
|
|
89
166
|
inputSchema: {
|
|
90
167
|
type: 'object',
|
|
91
168
|
properties: { model: { type: 'string' } },
|
|
@@ -95,6 +172,11 @@ export class ToolHandlers {
|
|
|
95
172
|
{
|
|
96
173
|
name: 'validate_model',
|
|
97
174
|
description: 'Check if a model ID exists',
|
|
175
|
+
annotations: {
|
|
176
|
+
readOnlyHint: true,
|
|
177
|
+
destructiveHint: false,
|
|
178
|
+
idempotentHint: true,
|
|
179
|
+
},
|
|
98
180
|
inputSchema: {
|
|
99
181
|
type: 'object',
|
|
100
182
|
properties: { model: { type: 'string' } },
|
|
@@ -104,46 +186,136 @@ export class ToolHandlers {
|
|
|
104
186
|
{
|
|
105
187
|
name: 'generate_image',
|
|
106
188
|
description: 'Generate an image from a text prompt',
|
|
189
|
+
annotations: {
|
|
190
|
+
readOnlyHint: false,
|
|
191
|
+
destructiveHint: false,
|
|
192
|
+
idempotentHint: false,
|
|
193
|
+
},
|
|
107
194
|
inputSchema: {
|
|
108
195
|
type: 'object',
|
|
109
196
|
properties: {
|
|
110
197
|
prompt: { type: 'string' },
|
|
111
198
|
model: { type: 'string' },
|
|
112
|
-
save_path: {
|
|
199
|
+
save_path: {
|
|
200
|
+
type: 'string',
|
|
201
|
+
description: 'Optional path to save the image. Routed through the OPENROUTER_OUTPUT_DIR sandbox.',
|
|
202
|
+
},
|
|
113
203
|
},
|
|
114
204
|
required: ['prompt'],
|
|
115
205
|
},
|
|
116
206
|
},
|
|
117
207
|
{
|
|
118
|
-
name: '
|
|
119
|
-
description: '
|
|
208
|
+
name: 'generate_audio',
|
|
209
|
+
description: 'Generate audio from a text prompt. Conversational models (e.g. openai/gpt-audio) respond in spoken audio. Music models (e.g. google/lyria-3-clip-preview) need a structured prompt. Output format is auto-detected and file extension is corrected automatically.',
|
|
210
|
+
annotations: {
|
|
211
|
+
readOnlyHint: false,
|
|
212
|
+
destructiveHint: false,
|
|
213
|
+
idempotentHint: false,
|
|
214
|
+
},
|
|
120
215
|
inputSchema: {
|
|
121
216
|
type: 'object',
|
|
122
217
|
properties: {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
218
|
+
prompt: { type: 'string', description: 'Text input' },
|
|
219
|
+
model: { type: 'string', description: 'Model ID (default: openai/gpt-audio)' },
|
|
220
|
+
voice: { type: 'string', description: 'Voice name (default: alloy)' },
|
|
221
|
+
format: {
|
|
222
|
+
type: 'string',
|
|
223
|
+
description: 'Requested format: pcm16 (default), mp3, flac, opus',
|
|
224
|
+
},
|
|
225
|
+
save_path: {
|
|
226
|
+
type: 'string',
|
|
227
|
+
description: 'Optional path to save the audio. Extension auto-corrected and routed through OPENROUTER_OUTPUT_DIR sandbox.',
|
|
228
|
+
},
|
|
126
229
|
},
|
|
127
|
-
required: ['
|
|
230
|
+
required: ['prompt'],
|
|
128
231
|
},
|
|
129
232
|
},
|
|
130
233
|
{
|
|
131
|
-
name: '
|
|
132
|
-
description: 'Generate
|
|
133
|
-
'
|
|
134
|
-
'
|
|
234
|
+
name: 'generate_video',
|
|
235
|
+
description: 'Generate a video from a text prompt using an OpenRouter video-generation model (default: google/veo-3.1). ' +
|
|
236
|
+
'Submits an async job, polls until completion or max_wait_ms, then downloads the result. ' +
|
|
237
|
+
'Optionally conditioned on first/last-frame images or reference images. ' +
|
|
238
|
+
'Large outputs are auto-saved when save_path is provided and path-sandboxed.',
|
|
239
|
+
annotations: {
|
|
240
|
+
readOnlyHint: false,
|
|
241
|
+
destructiveHint: false,
|
|
242
|
+
idempotentHint: false,
|
|
243
|
+
},
|
|
135
244
|
inputSchema: {
|
|
136
245
|
type: 'object',
|
|
137
246
|
properties: {
|
|
138
|
-
prompt: { type: 'string', description: 'Text
|
|
139
|
-
model: { type: 'string', description: '
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
247
|
+
prompt: { type: 'string', description: 'Text description of the desired video.' },
|
|
248
|
+
model: { type: 'string', description: 'Override the video model ID.' },
|
|
249
|
+
resolution: {
|
|
250
|
+
type: 'string',
|
|
251
|
+
description: '480p / 720p / 1080p / 1K / 2K / 4K (model-dependent).',
|
|
252
|
+
},
|
|
253
|
+
aspect_ratio: {
|
|
254
|
+
type: 'string',
|
|
255
|
+
description: '16:9 / 9:16 / 1:1 / 4:3 / 3:4 / 21:9 / 9:21 (model-dependent).',
|
|
256
|
+
},
|
|
257
|
+
duration: {
|
|
258
|
+
type: 'number',
|
|
259
|
+
minimum: 1,
|
|
260
|
+
description: 'Duration in seconds (model-dependent).',
|
|
261
|
+
},
|
|
262
|
+
seed: { type: 'number', description: 'Deterministic seed when supported.' },
|
|
263
|
+
first_frame_image: {
|
|
264
|
+
type: 'string',
|
|
265
|
+
description: 'Optional image (path, URL, or data URL) used as the first frame for image-to-video.',
|
|
266
|
+
},
|
|
267
|
+
last_frame_image: {
|
|
268
|
+
type: 'string',
|
|
269
|
+
description: 'Optional image used as the last frame for frame transitions.',
|
|
270
|
+
},
|
|
271
|
+
reference_images: {
|
|
272
|
+
type: 'array',
|
|
273
|
+
items: { type: 'string' },
|
|
274
|
+
description: 'Optional style/content reference images.',
|
|
275
|
+
},
|
|
276
|
+
provider: {
|
|
277
|
+
type: 'object',
|
|
278
|
+
description: 'Provider-specific passthrough options keyed by provider slug.',
|
|
279
|
+
},
|
|
280
|
+
save_path: {
|
|
281
|
+
type: 'string',
|
|
282
|
+
description: 'Where to save the video. Routed through the OPENROUTER_OUTPUT_DIR sandbox; extension auto-corrected.',
|
|
283
|
+
},
|
|
284
|
+
max_wait_ms: {
|
|
285
|
+
type: 'number',
|
|
286
|
+
minimum: 10000,
|
|
287
|
+
description: 'Total time to wait for the async job before returning a resumable handle (default 600000 ms).',
|
|
288
|
+
},
|
|
289
|
+
poll_interval_ms: {
|
|
290
|
+
type: 'number',
|
|
291
|
+
minimum: 2000,
|
|
292
|
+
description: 'Polling cadence (default 15000 ms).',
|
|
293
|
+
},
|
|
143
294
|
},
|
|
144
295
|
required: ['prompt'],
|
|
145
296
|
},
|
|
146
297
|
},
|
|
298
|
+
{
|
|
299
|
+
name: 'get_video_status',
|
|
300
|
+
description: 'Resume a previously submitted video generation job by id. Returns the latest status; if completed, ' +
|
|
301
|
+
'downloads the video (and saves it when save_path is provided).',
|
|
302
|
+
annotations: {
|
|
303
|
+
readOnlyHint: true,
|
|
304
|
+
destructiveHint: false,
|
|
305
|
+
idempotentHint: true,
|
|
306
|
+
},
|
|
307
|
+
inputSchema: {
|
|
308
|
+
type: 'object',
|
|
309
|
+
properties: {
|
|
310
|
+
video_id: { type: 'string', description: 'Job id from a previous generate_video call.' },
|
|
311
|
+
save_path: {
|
|
312
|
+
type: 'string',
|
|
313
|
+
description: 'Optional save path (applies when the job is already completed).',
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
required: ['video_id'],
|
|
317
|
+
},
|
|
318
|
+
},
|
|
147
319
|
],
|
|
148
320
|
}));
|
|
149
321
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -153,6 +325,10 @@ export class ToolHandlers {
|
|
|
153
325
|
return handleChatCompletion(wrapToolArgs(args), this.openai, this.defaultModel);
|
|
154
326
|
case 'analyze_image':
|
|
155
327
|
return handleAnalyzeImage(wrapToolArgs(args), this.openai, this.defaultModel);
|
|
328
|
+
case 'analyze_audio':
|
|
329
|
+
return handleAnalyzeAudio(wrapToolArgs(args), this.openai, this.defaultModel);
|
|
330
|
+
case 'analyze_video':
|
|
331
|
+
return handleAnalyzeVideo(wrapToolArgs(args), this.openai, this.defaultModel);
|
|
156
332
|
case 'search_models':
|
|
157
333
|
return handleSearchModels(wrapToolArgs(args), this.apiClient, this.modelCache);
|
|
158
334
|
case 'get_model_info':
|
|
@@ -161,12 +337,14 @@ export class ToolHandlers {
|
|
|
161
337
|
return handleValidateModel(wrapToolArgs(args), this.modelCache, this.apiClient);
|
|
162
338
|
case 'generate_image':
|
|
163
339
|
return handleGenerateImage(wrapToolArgs(args), this.openai);
|
|
164
|
-
case 'analyze_audio':
|
|
165
|
-
return handleAnalyzeAudio(wrapToolArgs(args), this.openai, this.defaultModel);
|
|
166
340
|
case 'generate_audio':
|
|
167
341
|
return handleGenerateAudio(wrapToolArgs(args), this.openai);
|
|
342
|
+
case 'generate_video':
|
|
343
|
+
return handleGenerateVideo(wrapToolArgs(args), this.apiClient);
|
|
344
|
+
case 'get_video_status':
|
|
345
|
+
return handleGetVideoStatus(wrapToolArgs(args), this.apiClient);
|
|
168
346
|
default:
|
|
169
|
-
throw new McpError(
|
|
347
|
+
throw new McpError(McpErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
170
348
|
}
|
|
171
349
|
});
|
|
172
350
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stabgan/openrouter-mcp-multimodal",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"mcpName": "io.github.stabgan/openrouter-multimodal",
|
|
5
|
-
"description": "MCP server for OpenRouter with text chat, image analysis
|
|
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",
|
|
7
7
|
"main": "dist/index.js",
|
|
8
8
|
"bin": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc && shx chmod +x dist/*.js",
|
|
17
|
-
"
|
|
17
|
+
"prepublishOnly": "npm run build",
|
|
18
18
|
"start": "node dist/index.js",
|
|
19
19
|
"lint": "eslint src",
|
|
20
20
|
"format": "prettier --write \"src/**/*.ts\" *.json \"*.md\"",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { getAudioFormat, getAudioMimeType, prepareAudioData, isBlockedIPv4, assertUrlSafeForFetch, SUPPORTED_AUDIO_FORMATS, } from '../tool-handlers/audio-utils.js';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import { writeFileSync, unlinkSync } from 'fs';
|
|
5
|
-
import { tmpdir } from 'os';
|
|
6
|
-
describe('getAudioFormat', () => {
|
|
7
|
-
it('returns correct format for supported extensions', () => {
|
|
8
|
-
expect(getAudioFormat('audio.wav')).toBe('wav');
|
|
9
|
-
expect(getAudioFormat('audio.mp3')).toBe('mp3');
|
|
10
|
-
expect(getAudioFormat('audio.flac')).toBe('flac');
|
|
11
|
-
expect(getAudioFormat('audio.ogg')).toBe('ogg');
|
|
12
|
-
expect(getAudioFormat('audio.aac')).toBe('aac');
|
|
13
|
-
expect(getAudioFormat('audio.m4a')).toBe('m4a');
|
|
14
|
-
expect(getAudioFormat('audio.aiff')).toBe('aiff');
|
|
15
|
-
});
|
|
16
|
-
it('returns undefined for unsupported extensions', () => {
|
|
17
|
-
expect(getAudioFormat('audio.xyz')).toBeUndefined();
|
|
18
|
-
expect(getAudioFormat('audio.mid')).toBeUndefined();
|
|
19
|
-
expect(getAudioFormat('noext')).toBeUndefined();
|
|
20
|
-
});
|
|
21
|
-
it('returns undefined for API-only formats (pcm16/pcm24 are not file extensions)', () => {
|
|
22
|
-
expect(getAudioFormat('audio.pcm16')).toBeUndefined();
|
|
23
|
-
expect(getAudioFormat('audio.pcm24')).toBeUndefined();
|
|
24
|
-
});
|
|
25
|
-
it('handles uppercase extensions', () => {
|
|
26
|
-
expect(getAudioFormat('audio.WAV')).toBe('wav');
|
|
27
|
-
expect(getAudioFormat('audio.MP3')).toBe('mp3');
|
|
28
|
-
expect(getAudioFormat('audio.FLAC')).toBe('flac');
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
describe('getAudioMimeType', () => {
|
|
32
|
-
it('returns correct MIME types', () => {
|
|
33
|
-
expect(getAudioMimeType('wav')).toBe('audio/wav');
|
|
34
|
-
expect(getAudioMimeType('mp3')).toBe('audio/mpeg');
|
|
35
|
-
expect(getAudioMimeType('flac')).toBe('audio/flac');
|
|
36
|
-
expect(getAudioMimeType('ogg')).toBe('audio/ogg');
|
|
37
|
-
expect(getAudioMimeType('aac')).toBe('audio/aac');
|
|
38
|
-
expect(getAudioMimeType('m4a')).toBe('audio/mp4');
|
|
39
|
-
expect(getAudioMimeType('aiff')).toBe('audio/aiff');
|
|
40
|
-
expect(getAudioMimeType('pcm16')).toBe('audio/pcm');
|
|
41
|
-
expect(getAudioMimeType('pcm24')).toBe('audio/pcm');
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
describe('SUPPORTED_AUDIO_FORMATS', () => {
|
|
45
|
-
it('includes file formats and API formats', () => {
|
|
46
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('wav');
|
|
47
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('mp3');
|
|
48
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('flac');
|
|
49
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('ogg');
|
|
50
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('aac');
|
|
51
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('m4a');
|
|
52
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('pcm16');
|
|
53
|
-
expect(SUPPORTED_AUDIO_FORMATS).toContain('pcm24');
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
describe('prepareAudioData', () => {
|
|
57
|
-
it('decodes base64 data URLs with correct format', async () => {
|
|
58
|
-
const audioData = Buffer.from('fake-audio-data').toString('base64');
|
|
59
|
-
const result = await prepareAudioData(`data:audio/wav;base64,${audioData}`);
|
|
60
|
-
expect(result.data).toBe(audioData);
|
|
61
|
-
expect(result.format).toBe('wav');
|
|
62
|
-
});
|
|
63
|
-
it('maps audio/mpeg MIME to mp3 format', async () => {
|
|
64
|
-
const audioData = Buffer.from('fake-audio-data').toString('base64');
|
|
65
|
-
const result = await prepareAudioData(`data:audio/mpeg;base64,${audioData}`);
|
|
66
|
-
expect(result.data).toBe(audioData);
|
|
67
|
-
expect(result.format).toBe('mp3');
|
|
68
|
-
});
|
|
69
|
-
it('rejects invalid data URLs', async () => {
|
|
70
|
-
await expect(prepareAudioData('data:invalid')).rejects.toThrow('Invalid data URL');
|
|
71
|
-
});
|
|
72
|
-
it('rejects unsupported MIME types', async () => {
|
|
73
|
-
const audioData = Buffer.from('fake').toString('base64');
|
|
74
|
-
await expect(prepareAudioData(`data:audio/xyz;base64,${audioData}`)).rejects.toThrow('Unsupported audio format');
|
|
75
|
-
});
|
|
76
|
-
it('reads local files and returns base64 with format', async () => {
|
|
77
|
-
const tmpFile = path.join(tmpdir(), `test-audio-${Date.now()}.wav`);
|
|
78
|
-
writeFileSync(tmpFile, Buffer.from('fake-audio-content'));
|
|
79
|
-
try {
|
|
80
|
-
const result = await prepareAudioData(tmpFile);
|
|
81
|
-
expect(result.data).toBe(Buffer.from('fake-audio-content').toString('base64'));
|
|
82
|
-
expect(result.format).toBe('wav');
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
unlinkSync(tmpFile);
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
it('throws on missing files', async () => {
|
|
89
|
-
await expect(prepareAudioData('/nonexistent/path/audio.wav')).rejects.toThrow();
|
|
90
|
-
});
|
|
91
|
-
it('throws on unsupported file extensions', async () => {
|
|
92
|
-
const tmpFile = path.join(tmpdir(), `test-audio-${Date.now()}.xyz`);
|
|
93
|
-
writeFileSync(tmpFile, Buffer.from('fake'));
|
|
94
|
-
try {
|
|
95
|
-
await expect(prepareAudioData(tmpFile)).rejects.toThrow('Unsupported audio format');
|
|
96
|
-
}
|
|
97
|
-
finally {
|
|
98
|
-
unlinkSync(tmpFile);
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
it('rejects private IPv4 URLs', async () => {
|
|
102
|
-
await expect(prepareAudioData('http://127.0.0.1:8080/audio.wav')).rejects.toThrow();
|
|
103
|
-
await expect(prepareAudioData('http://192.168.1.1/audio.mp3')).rejects.toThrow();
|
|
104
|
-
});
|
|
105
|
-
it('rejects localhost hostnames', async () => {
|
|
106
|
-
await expect(assertUrlSafeForFetch('http://localhost/audio.wav')).rejects.toThrow();
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
describe('isBlockedIPv4 (re-exported from fetch-utils)', () => {
|
|
110
|
-
it('identifies loopback and RFC1918', () => {
|
|
111
|
-
expect(isBlockedIPv4('127.0.0.1')).toBe(true);
|
|
112
|
-
expect(isBlockedIPv4('10.0.0.1')).toBe(true);
|
|
113
|
-
expect(isBlockedIPv4('192.168.1.1')).toBe(true);
|
|
114
|
-
expect(isBlockedIPv4('172.16.0.1')).toBe(true);
|
|
115
|
-
expect(isBlockedIPv4('8.8.8.8')).toBe(false);
|
|
116
|
-
});
|
|
117
|
-
it('blocks metadata endpoint IP', () => {
|
|
118
|
-
expect(isBlockedIPv4('169.254.169.254')).toBe(true);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { readEnvInt, isBlockedIPv4, assertUrlSafeForFetch } from '../tool-handlers/fetch-utils.js';
|
|
3
|
-
describe('readEnvInt', () => {
|
|
4
|
-
it('returns fallback when env var is missing', () => {
|
|
5
|
-
delete process.env['TEST_MISSING_VAR'];
|
|
6
|
-
expect(readEnvInt('TEST_MISSING_VAR', 42)).toBe(42);
|
|
7
|
-
});
|
|
8
|
-
it('returns fallback when env var is empty', () => {
|
|
9
|
-
process.env['TEST_EMPTY_VAR'] = '';
|
|
10
|
-
expect(readEnvInt('TEST_EMPTY_VAR', 42)).toBe(42);
|
|
11
|
-
delete process.env['TEST_EMPTY_VAR'];
|
|
12
|
-
});
|
|
13
|
-
it('parses valid integer', () => {
|
|
14
|
-
process.env['TEST_INT_VAR'] = '100';
|
|
15
|
-
expect(readEnvInt('TEST_INT_VAR', 42)).toBe(100);
|
|
16
|
-
delete process.env['TEST_INT_VAR'];
|
|
17
|
-
});
|
|
18
|
-
it('returns fallback for non-numeric value', () => {
|
|
19
|
-
process.env['TEST_NAN_VAR'] = 'abc';
|
|
20
|
-
expect(readEnvInt('TEST_NAN_VAR', 42)).toBe(42);
|
|
21
|
-
delete process.env['TEST_NAN_VAR'];
|
|
22
|
-
});
|
|
23
|
-
it('returns fallback when value is below min', () => {
|
|
24
|
-
process.env['TEST_LOW_VAR'] = '0';
|
|
25
|
-
expect(readEnvInt('TEST_LOW_VAR', 42, 1)).toBe(42);
|
|
26
|
-
delete process.env['TEST_LOW_VAR'];
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
describe('isBlockedIPv4', () => {
|
|
30
|
-
it('blocks loopback', () => {
|
|
31
|
-
expect(isBlockedIPv4('127.0.0.1')).toBe(true);
|
|
32
|
-
expect(isBlockedIPv4('127.255.255.255')).toBe(true);
|
|
33
|
-
});
|
|
34
|
-
it('blocks RFC1918 10.x', () => {
|
|
35
|
-
expect(isBlockedIPv4('10.0.0.1')).toBe(true);
|
|
36
|
-
expect(isBlockedIPv4('10.255.255.255')).toBe(true);
|
|
37
|
-
});
|
|
38
|
-
it('blocks RFC1918 172.16-31.x', () => {
|
|
39
|
-
expect(isBlockedIPv4('172.16.0.1')).toBe(true);
|
|
40
|
-
expect(isBlockedIPv4('172.31.255.255')).toBe(true);
|
|
41
|
-
});
|
|
42
|
-
it('blocks RFC1918 192.168.x', () => {
|
|
43
|
-
expect(isBlockedIPv4('192.168.0.1')).toBe(true);
|
|
44
|
-
expect(isBlockedIPv4('192.168.255.255')).toBe(true);
|
|
45
|
-
});
|
|
46
|
-
it('blocks link-local 169.254.x', () => {
|
|
47
|
-
expect(isBlockedIPv4('169.254.169.254')).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
it('blocks CGNAT 100.64-127.x', () => {
|
|
50
|
-
expect(isBlockedIPv4('100.64.0.1')).toBe(true);
|
|
51
|
-
expect(isBlockedIPv4('100.127.255.255')).toBe(true);
|
|
52
|
-
});
|
|
53
|
-
it('allows public IPs', () => {
|
|
54
|
-
expect(isBlockedIPv4('8.8.8.8')).toBe(false);
|
|
55
|
-
expect(isBlockedIPv4('1.1.1.1')).toBe(false);
|
|
56
|
-
expect(isBlockedIPv4('142.250.80.46')).toBe(false);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
describe('assertUrlSafeForFetch', () => {
|
|
60
|
-
it('rejects localhost', async () => {
|
|
61
|
-
await expect(assertUrlSafeForFetch('http://localhost/foo')).rejects.toThrow('Blocked host');
|
|
62
|
-
});
|
|
63
|
-
it('rejects private IPv4', async () => {
|
|
64
|
-
await expect(assertUrlSafeForFetch('http://127.0.0.1/foo')).rejects.toThrow('Blocked host');
|
|
65
|
-
await expect(assertUrlSafeForFetch('http://192.168.1.1/foo')).rejects.toThrow('Blocked host');
|
|
66
|
-
});
|
|
67
|
-
it('rejects non-HTTP protocols', async () => {
|
|
68
|
-
await expect(assertUrlSafeForFetch('ftp://example.com/foo')).rejects.toThrow('Only HTTP(S)');
|
|
69
|
-
});
|
|
70
|
-
it('rejects URLs with credentials', async () => {
|
|
71
|
-
await expect(assertUrlSafeForFetch('http://user:pass@example.com/foo')).rejects.toThrow('credentials');
|
|
72
|
-
});
|
|
73
|
-
it('rejects invalid URLs', async () => {
|
|
74
|
-
await expect(assertUrlSafeForFetch('not-a-url')).rejects.toThrow('Invalid URL');
|
|
75
|
-
});
|
|
76
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { createWavHeader, detectAudioFormat, wrapPcmInWav, replaceExtension, } from '../tool-handlers/generate-audio.js';
|
|
3
|
-
describe('createWavHeader', () => {
|
|
4
|
-
it('produces a 44-byte buffer', () => {
|
|
5
|
-
const header = createWavHeader(1000);
|
|
6
|
-
expect(header.length).toBe(44);
|
|
7
|
-
});
|
|
8
|
-
it('starts with RIFF...WAVE', () => {
|
|
9
|
-
const header = createWavHeader(1000);
|
|
10
|
-
expect(header.subarray(0, 4).toString('ascii')).toBe('RIFF');
|
|
11
|
-
expect(header.subarray(8, 12).toString('ascii')).toBe('WAVE');
|
|
12
|
-
});
|
|
13
|
-
it('has correct file size field (36 + dataLength)', () => {
|
|
14
|
-
const header = createWavHeader(1000);
|
|
15
|
-
expect(header.readUInt32LE(4)).toBe(36 + 1000);
|
|
16
|
-
});
|
|
17
|
-
it('has PCM format (1)', () => {
|
|
18
|
-
const header = createWavHeader(1000);
|
|
19
|
-
expect(header.readUInt16LE(20)).toBe(1);
|
|
20
|
-
});
|
|
21
|
-
it('has correct data chunk size', () => {
|
|
22
|
-
const header = createWavHeader(2048);
|
|
23
|
-
expect(header.readUInt32LE(40)).toBe(2048);
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
describe('detectAudioFormat', () => {
|
|
27
|
-
it('detects MP3 with ID3 tag', () => {
|
|
28
|
-
const buf = Buffer.from([0x49, 0x44, 0x33, 0x00, 0x00]);
|
|
29
|
-
expect(detectAudioFormat(buf)).toEqual({ ext: 'mp3', mimeType: 'audio/mpeg' });
|
|
30
|
-
});
|
|
31
|
-
it('detects MP3 frame sync (MPEG1 Layer3 = 0xFF 0xFB)', () => {
|
|
32
|
-
const buf = Buffer.from([0xff, 0xfb, 0x90, 0x00]);
|
|
33
|
-
expect(detectAudioFormat(buf)).toEqual({ ext: 'mp3', mimeType: 'audio/mpeg' });
|
|
34
|
-
});
|
|
35
|
-
it('rejects reserved MP3 version bits (0x01)', () => {
|
|
36
|
-
// 0xFF 0xE8 → version bits = (0xE8 >> 3) & 0x03 = 0x01 (reserved)
|
|
37
|
-
const buf = Buffer.from([0xff, 0xe8, 0x00, 0x00]);
|
|
38
|
-
expect(detectAudioFormat(buf).ext).not.toBe('mp3');
|
|
39
|
-
});
|
|
40
|
-
it('detects WAV (RIFF...WAVE)', () => {
|
|
41
|
-
const buf = Buffer.alloc(12);
|
|
42
|
-
buf.write('RIFF', 0);
|
|
43
|
-
buf.writeUInt32LE(100, 4);
|
|
44
|
-
buf.write('WAVE', 8);
|
|
45
|
-
expect(detectAudioFormat(buf)).toEqual({ ext: 'wav', mimeType: 'audio/wav' });
|
|
46
|
-
});
|
|
47
|
-
it('detects FLAC', () => {
|
|
48
|
-
const buf = Buffer.from('fLaC\x00\x00', 'ascii');
|
|
49
|
-
expect(detectAudioFormat(buf)).toEqual({ ext: 'flac', mimeType: 'audio/flac' });
|
|
50
|
-
});
|
|
51
|
-
it('detects OGG', () => {
|
|
52
|
-
const buf = Buffer.from('OggS\x00\x00', 'ascii');
|
|
53
|
-
expect(detectAudioFormat(buf)).toEqual({ ext: 'ogg', mimeType: 'audio/ogg' });
|
|
54
|
-
});
|
|
55
|
-
it('defaults to pcm for unknown data', () => {
|
|
56
|
-
const buf = Buffer.from([0x00, 0x01, 0x02, 0x03]);
|
|
57
|
-
expect(detectAudioFormat(buf)).toEqual({ ext: 'pcm', mimeType: 'audio/pcm' });
|
|
58
|
-
});
|
|
59
|
-
it('defaults to pcm for empty buffer', () => {
|
|
60
|
-
expect(detectAudioFormat(Buffer.alloc(0)).ext).toBe('pcm');
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
describe('wrapPcmInWav', () => {
|
|
64
|
-
it('prepends 44-byte WAV header', () => {
|
|
65
|
-
const pcm = Buffer.from([0x00, 0x01, 0x02, 0x03]);
|
|
66
|
-
const wav = wrapPcmInWav(pcm);
|
|
67
|
-
expect(wav.length).toBe(44 + 4);
|
|
68
|
-
expect(wav.subarray(0, 4).toString('ascii')).toBe('RIFF');
|
|
69
|
-
expect(wav.subarray(8, 12).toString('ascii')).toBe('WAVE');
|
|
70
|
-
});
|
|
71
|
-
it('detected as WAV after wrapping', () => {
|
|
72
|
-
const pcm = Buffer.alloc(100);
|
|
73
|
-
const wav = wrapPcmInWav(pcm);
|
|
74
|
-
expect(detectAudioFormat(wav)).toEqual({ ext: 'wav', mimeType: 'audio/wav' });
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
describe('replaceExtension', () => {
|
|
78
|
-
it('replaces existing extension', () => {
|
|
79
|
-
expect(replaceExtension('output.wav', 'mp3')).toBe('output.mp3');
|
|
80
|
-
});
|
|
81
|
-
it('appends extension when none exists', () => {
|
|
82
|
-
expect(replaceExtension('output', 'wav')).toBe('output.wav');
|
|
83
|
-
});
|
|
84
|
-
it('handles nested paths', () => {
|
|
85
|
-
expect(replaceExtension('/tmp/audio/file.wav', 'mp3')).toBe('/tmp/audio/file.mp3');
|
|
86
|
-
});
|
|
87
|
-
it('handles dotfiles', () => {
|
|
88
|
-
expect(replaceExtension('.hidden.wav', 'mp3')).toBe('.hidden.mp3');
|
|
89
|
-
});
|
|
90
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|