@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
@@ -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: { type: 'object', properties: { vision: { type: 'boolean' } } },
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,166 @@ 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: { type: 'string' },
199
+ aspect_ratio: {
200
+ type: 'string',
201
+ description: 'Output aspect ratio (e.g. 1:1, 16:9, 9:16, 4:3, 3:4, 21:9). Model-dependent.',
202
+ enum: [
203
+ '1:1',
204
+ '2:3',
205
+ '3:2',
206
+ '3:4',
207
+ '4:3',
208
+ '4:5',
209
+ '5:4',
210
+ '9:16',
211
+ '16:9',
212
+ '21:9',
213
+ '1:4',
214
+ '4:1',
215
+ '1:8',
216
+ '8:1',
217
+ ],
218
+ },
219
+ image_size: {
220
+ type: 'string',
221
+ description: 'Output resolution bucket. 1K is the default; 0.5K / 2K / 4K are model-dependent.',
222
+ enum: ['0.5K', '1K', '2K', '4K'],
223
+ },
224
+ max_tokens: {
225
+ type: 'number',
226
+ minimum: 1,
227
+ description: 'Cap on completion tokens. Defaults to the model context window, which can trip free-tier quotas; set e.g. 4096 on low-credit accounts.',
228
+ },
229
+ save_path: {
230
+ type: 'string',
231
+ description: 'Optional path to save the image. Routed through the OPENROUTER_OUTPUT_DIR sandbox.',
232
+ },
113
233
  },
114
234
  required: ['prompt'],
115
235
  },
116
236
  },
117
237
  {
118
- name: 'analyze_audio',
119
- description: 'Analyze or transcribe an audio file using a multimodal model',
238
+ name: 'generate_audio',
239
+ 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.',
240
+ annotations: {
241
+ readOnlyHint: false,
242
+ destructiveHint: false,
243
+ idempotentHint: false,
244
+ },
120
245
  inputSchema: {
121
246
  type: 'object',
122
247
  properties: {
123
- audio_path: { type: 'string', description: 'File path, URL, or data URL (base64-encoded audio)' },
124
- question: { type: 'string', description: 'Question or instruction about the audio (default: transcribe)' },
125
- model: { type: 'string' },
248
+ prompt: { type: 'string', description: 'Text input' },
249
+ model: { type: 'string', description: 'Model ID (default: openai/gpt-audio)' },
250
+ voice: { type: 'string', description: 'Voice name (default: alloy)' },
251
+ format: {
252
+ type: 'string',
253
+ description: 'Requested format: pcm16 (default), mp3, flac, opus',
254
+ },
255
+ save_path: {
256
+ type: 'string',
257
+ description: 'Optional path to save the audio. Extension auto-corrected and routed through OPENROUTER_OUTPUT_DIR sandbox.',
258
+ },
126
259
  },
127
- required: ['audio_path'],
260
+ required: ['prompt'],
128
261
  },
129
262
  },
130
263
  {
131
- name: 'generate_audio',
132
- description: 'Generate audio from a text prompt. Conversational models (e.g. openai/gpt-audio) respond in spoken audio. ' +
133
- 'Music models (e.g. google/lyria-3-clip-preview) need a structured prompt. ' +
134
- 'Output format is auto-detected and file extension is corrected automatically.',
264
+ name: 'generate_video',
265
+ description: 'Generate a video from a text prompt using an OpenRouter video-generation model (default: google/veo-3.1). ' +
266
+ 'Submits an async job, polls until completion or max_wait_ms, then downloads the result. ' +
267
+ 'Optionally conditioned on first/last-frame images or reference images. ' +
268
+ 'Large outputs are auto-saved when save_path is provided and path-sandboxed.',
269
+ annotations: {
270
+ readOnlyHint: false,
271
+ destructiveHint: false,
272
+ idempotentHint: false,
273
+ },
135
274
  inputSchema: {
136
275
  type: 'object',
137
276
  properties: {
138
- prompt: { type: 'string', description: 'Text input' },
139
- model: { type: 'string', description: 'Model ID (default: openai/gpt-audio)' },
140
- voice: { type: 'string', description: 'Voice name (default: alloy)' },
141
- format: { type: 'string', description: 'Requested format: pcm16 (default), mp3, flac, opus' },
142
- save_path: { type: 'string', description: 'Path to save audio file. Extension auto-corrected.' },
277
+ prompt: { type: 'string', description: 'Text description of the desired video.' },
278
+ model: { type: 'string', description: 'Override the video model ID.' },
279
+ resolution: {
280
+ type: 'string',
281
+ description: '480p / 720p / 1080p / 1K / 2K / 4K (model-dependent).',
282
+ },
283
+ aspect_ratio: {
284
+ type: 'string',
285
+ description: '16:9 / 9:16 / 1:1 / 4:3 / 3:4 / 21:9 / 9:21 (model-dependent).',
286
+ },
287
+ duration: {
288
+ type: 'number',
289
+ minimum: 1,
290
+ description: 'Duration in seconds (model-dependent).',
291
+ },
292
+ seed: { type: 'number', description: 'Deterministic seed when supported.' },
293
+ first_frame_image: {
294
+ type: 'string',
295
+ description: 'Optional image (path, URL, or data URL) used as the first frame for image-to-video.',
296
+ },
297
+ last_frame_image: {
298
+ type: 'string',
299
+ description: 'Optional image used as the last frame for frame transitions.',
300
+ },
301
+ reference_images: {
302
+ type: 'array',
303
+ items: { type: 'string' },
304
+ description: 'Optional style/content reference images.',
305
+ },
306
+ provider: {
307
+ type: 'object',
308
+ description: 'Provider-specific passthrough options keyed by provider slug.',
309
+ },
310
+ save_path: {
311
+ type: 'string',
312
+ description: 'Where to save the video. Routed through the OPENROUTER_OUTPUT_DIR sandbox; extension auto-corrected.',
313
+ },
314
+ max_wait_ms: {
315
+ type: 'number',
316
+ minimum: 10000,
317
+ description: 'Total time to wait for the async job before returning a resumable handle (default 600000 ms).',
318
+ },
319
+ poll_interval_ms: {
320
+ type: 'number',
321
+ minimum: 2000,
322
+ description: 'Polling cadence (default 15000 ms).',
323
+ },
143
324
  },
144
325
  required: ['prompt'],
145
326
  },
146
327
  },
328
+ {
329
+ name: 'get_video_status',
330
+ description: 'Resume a previously submitted video generation job by id. Returns the latest status; if completed, ' +
331
+ 'downloads the video (and saves it when save_path is provided).',
332
+ annotations: {
333
+ readOnlyHint: true,
334
+ destructiveHint: false,
335
+ idempotentHint: true,
336
+ },
337
+ inputSchema: {
338
+ type: 'object',
339
+ properties: {
340
+ video_id: { type: 'string', description: 'Job id from a previous generate_video call.' },
341
+ save_path: {
342
+ type: 'string',
343
+ description: 'Optional save path (applies when the job is already completed).',
344
+ },
345
+ },
346
+ required: ['video_id'],
347
+ },
348
+ },
147
349
  ],
148
350
  }));
149
351
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -153,6 +355,10 @@ export class ToolHandlers {
153
355
  return handleChatCompletion(wrapToolArgs(args), this.openai, this.defaultModel);
154
356
  case 'analyze_image':
155
357
  return handleAnalyzeImage(wrapToolArgs(args), this.openai, this.defaultModel);
358
+ case 'analyze_audio':
359
+ return handleAnalyzeAudio(wrapToolArgs(args), this.openai, this.defaultModel);
360
+ case 'analyze_video':
361
+ return handleAnalyzeVideo(wrapToolArgs(args), this.openai, this.defaultModel);
156
362
  case 'search_models':
157
363
  return handleSearchModels(wrapToolArgs(args), this.apiClient, this.modelCache);
158
364
  case 'get_model_info':
@@ -161,12 +367,14 @@ export class ToolHandlers {
161
367
  return handleValidateModel(wrapToolArgs(args), this.modelCache, this.apiClient);
162
368
  case 'generate_image':
163
369
  return handleGenerateImage(wrapToolArgs(args), this.openai);
164
- case 'analyze_audio':
165
- return handleAnalyzeAudio(wrapToolArgs(args), this.openai, this.defaultModel);
166
370
  case 'generate_audio':
167
371
  return handleGenerateAudio(wrapToolArgs(args), this.openai);
372
+ case 'generate_video':
373
+ return handleGenerateVideo(wrapToolArgs(args), this.apiClient);
374
+ case 'get_video_status':
375
+ return handleGetVideoStatus(wrapToolArgs(args), this.apiClient);
168
376
  default:
169
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
377
+ throw new McpError(McpErrorCode.MethodNotFound, `Unknown tool: ${name}`);
170
378
  }
171
379
  });
172
380
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@stabgan/openrouter-mcp-multimodal",
3
- "version": "2.0.0",
3
+ "version": "3.1.0",
4
4
  "mcpName": "io.github.stabgan/openrouter-multimodal",
5
- "description": "MCP server for OpenRouter with text chat, image analysis, image generation, audio analysis, and audio generation",
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
- "prepare": "npm run build",
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 {};