@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.
Files changed (54) hide show
  1. package/README.md +137 -43
  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 +128 -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 +21 -0
  23. package/dist/tool-handlers/fetch-utils.js +166 -11
  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 +26 -10
  27. package/dist/tool-handlers/generate-image.js +79 -27
  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.js +8 -2
  31. package/dist/tool-handlers/image-utils.d.ts +17 -1
  32. package/dist/tool-handlers/image-utils.js +66 -13
  33. package/dist/tool-handlers/openrouter-errors.d.ts +18 -0
  34. package/dist/tool-handlers/openrouter-errors.js +99 -0
  35. package/dist/tool-handlers/path-safety.d.ts +11 -0
  36. package/dist/tool-handlers/path-safety.js +88 -0
  37. package/dist/tool-handlers/search-models.js +1 -3
  38. package/dist/tool-handlers/validate-model.js +8 -2
  39. package/dist/tool-handlers/video-utils.d.ts +29 -0
  40. package/dist/tool-handlers/video-utils.js +174 -0
  41. package/dist/tool-handlers.js +199 -21
  42. package/package.json +3 -3
  43. package/dist/__tests__/audio-utils.test.d.ts +0 -1
  44. package/dist/__tests__/audio-utils.test.js +0 -120
  45. package/dist/__tests__/fetch-utils.test.d.ts +0 -1
  46. package/dist/__tests__/fetch-utils.test.js +0 -76
  47. package/dist/__tests__/generate-audio.test.d.ts +0 -1
  48. package/dist/__tests__/generate-audio.test.js +0 -90
  49. package/dist/__tests__/image-utils.test.d.ts +0 -1
  50. package/dist/__tests__/image-utils.test.js +0 -75
  51. package/dist/__tests__/integration.test.d.ts +0 -1
  52. package/dist/__tests__/integration.test.js +0 -219
  53. package/dist/__tests__/model-cache.test.d.ts +0 -1
  54. 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,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: { type: 'string' },
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: 'analyze_audio',
119
- description: 'Analyze or transcribe an audio file using a multimodal model',
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
- 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' },
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: ['audio_path'],
230
+ required: ['prompt'],
128
231
  },
129
232
  },
130
233
  {
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.',
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 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.' },
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(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
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": "2.0.0",
3
+ "version": "3.0.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 {};
@@ -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 {};